fixes 2026-10-21: evidence so far (R-1176 live proof, R-1192 freeze measured, R-1191 on 9202); design notes 03 §8a / 07 §7.0, §8.2a (D25) / 05 §16.5 (DST)
gates / gates (push) Successful in 13m34s
gates / gates (push) Successful in 13m34s
This commit is contained in:
1 parent
cd3d05981a
commit
ef4dbcf984
47 files changed
+3432
No files matched your search
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# bind_curl.sh — the household's bind page, endpoint-level (no browser on DooPlex): GET the page, then the same POST its
|
||||
# form sends. Env (from `sec run`, names passed as args): TOKEN_ENV PAIR_ENV PASS_ENV. Secrets go through 0600 files,
|
||||
# never a command line; prints status lines and the page's visible text only.
|
||||
set -euo pipefail
|
||||
TOK=${!1}; T=$(mktemp -d); chmod 700 "$T"; trap 'rm -rf "$T"' EXIT
|
||||
printf '%s' "${!2}" > "$T/pc"; printf '%s' "${!3}" > "$T/pp"
|
||||
U="https://hub.felhom.eu/bind/$TOK"
|
||||
curl -s -o "$T/g" -w "GET %{http_code}\n" "$U"
|
||||
curl -s -o "$T/p" -w "POST %{http_code}\n" --data-urlencode "pairing_code@$T/pc" --data-urlencode "passphrase@$T/pp" "$U"
|
||||
python3 - "$T/p" <<'PY'
|
||||
import re,sys,html
|
||||
t=open(sys.argv[1],encoding="utf-8").read()
|
||||
t=re.sub(r"<(script|style)[^>]*>.*?</\1>","",t,flags=re.S)
|
||||
print("answer:", re.sub(r"\s+"," ",html.unescape(re.sub(r"<[^>]+>"," ",t))).strip()[:400])
|
||||
PY
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctl.py — a minimal headless client for a Felhom controller dashboard (rehearsal 2026-10-20).
|
||||
|
||||
The same requests the dashboard's pages send (endpoint-level validation): POST /login, then JSON calls with the
|
||||
session cookie and the X-CSRF-Token header. Configuration by env: CTL_BASE (https://<ip>), CTL_DOMAIN (Host:
|
||||
felhom.<domain>), CTL_PW_ENV (the NAME of the env var holding the dashboard password — run under `sec run`).
|
||||
Session files live in a 0700 temp dir per process; nothing secret is printed.
|
||||
|
||||
usage as a CLI: ctl.py GET /api/stacks
|
||||
ctl.py POST /api/stacks/x/stop '{"a":1}'
|
||||
"""
|
||||
import json, os, re, subprocess, sys, tempfile
|
||||
|
||||
BASE = os.environ["CTL_BASE"]
|
||||
HOST = "Host: felhom." + os.environ["CTL_DOMAIN"]
|
||||
_T = tempfile.mkdtemp(prefix="ctl-")
|
||||
os.chmod(_T, 0o700)
|
||||
_SESS = _CSRF = None
|
||||
|
||||
|
||||
def _curl(args, inp=None, timeout=600):
|
||||
r = subprocess.run(["curl", "-sk", "-H", HOST] + args, capture_output=True, text=True, timeout=timeout, input=inp)
|
||||
return r.stdout
|
||||
|
||||
|
||||
def login():
|
||||
global _SESS, _CSRF
|
||||
pw = os.environ[os.environ["CTL_PW_ENV"]]
|
||||
hdr = os.path.join(_T, "h")
|
||||
# the password goes on stdin (curl --data-urlencode password@-), never on a command line
|
||||
_curl(["-D", hdr, "-o", "/dev/null", "-X", "POST", "--data-urlencode", "password@-", BASE + "/login"], inp=pw)
|
||||
m = re.search(r"felhom_session=[A-Za-z0-9._-]+", open(hdr).read(), re.I)
|
||||
if not m:
|
||||
sys.exit("ctl: login failed (no session cookie)")
|
||||
_SESS = m.group(0)
|
||||
body = _curl(["-L", "-H", "Cookie: " + _SESS, BASE + "/backups"]) # "/" now lands on /launcher, which has no csrf meta
|
||||
c = re.search(r'<meta name="csrf-token" content="([^"]+)"', body)
|
||||
if not c:
|
||||
sys.exit("ctl: login failed (no csrf token)")
|
||||
_CSRF = c.group(1)
|
||||
|
||||
|
||||
def call(method, path, data=None, raw=False, timeout=600):
|
||||
if _SESS is None:
|
||||
login()
|
||||
for attempt in (0, 1):
|
||||
args = ["-H", "Cookie: " + _SESS, "-w", "\n%{http_code}"]
|
||||
if method != "GET":
|
||||
args += ["-H", "X-CSRF-Token: " + _CSRF, "-H", "Content-Type: application/json", "-X", method]
|
||||
if data is not None:
|
||||
args += ["--data-binary", "@-"] # the body on stdin, never on a command line (it may hold a password)
|
||||
out = _curl(args + [BASE + path], inp=json.dumps(data) if data is not None and method != "GET" else None, timeout=timeout)
|
||||
body, _, code = out.rpartition("\n")
|
||||
if code in ("302", "401") and attempt == 0:
|
||||
login()
|
||||
continue
|
||||
if raw:
|
||||
return code, body
|
||||
try:
|
||||
return code, json.loads(body)
|
||||
except ValueError:
|
||||
return code, {"_raw": body[:800]}
|
||||
|
||||
|
||||
def page(path):
|
||||
if _SESS is None:
|
||||
login()
|
||||
return _curl(["-H", "Cookie: " + _SESS, BASE + path])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
m, p = sys.argv[1], sys.argv[2]
|
||||
d = json.loads(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
code, body = call(m, p, d)
|
||||
print(code)
|
||||
print(json.dumps(body, ensure_ascii=False, indent=1)[:6000])
|
||||
|
||||
|
||||
def form(path, fields, timeout=600):
|
||||
"""A form POST (application/x-www-form-urlencoded) with the session and the X-CSRF-Token header."""
|
||||
import urllib.parse
|
||||
if _SESS is None:
|
||||
login()
|
||||
out = _curl(["-H", "Cookie: " + _SESS, "-H", "X-CSRF-Token: " + _CSRF, "-w", "\n%{http_code}", "-X", "POST",
|
||||
"--data-binary", "@-", "-H", "Content-Type: application/x-www-form-urlencoded", BASE + path],
|
||||
inp=urllib.parse.urlencode(fields), timeout=timeout)
|
||||
body, _, code = out.rpartition("\n")
|
||||
try:
|
||||
return code, json.loads(body)
|
||||
except ValueError:
|
||||
return code, {"_raw": body[:800]}
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""fab_gate_proof.py — (fixes 2026-10-21: the data-loss hunt's C3, rerun on controller 0.337.0-rc.1 as the R-1191 live proof) C3 for R-1187 (e) on scratch 9202: does a `.fab` export → remove → import drop the family gate?
|
||||
|
||||
Glance has no login of its own; the catalog puts it ONLY behind the family gate (decision 221). Observables, each
|
||||
read from a DIFFERENT channel than the act: (1) the gate file `family-gate-glance.yml` on the guest's disk; (2) what a
|
||||
stranger's request to the app's host gets back from traefik (302 to the family page vs 200 from Glance).
|
||||
Acts are the dashboard's own endpoints: deploy, /api/export/start, stop+remove, /api/export/import.
|
||||
Env: CTL_BASE, CTL_DOMAIN, CTL_PW_ENV (tools/ctl.py).
|
||||
"""
|
||||
import json, os, subprocess, sys, time
|
||||
sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/fixes-2026-10-21/tools")
|
||||
import ctl # noqa: E402
|
||||
|
||||
APP, SUB = "glance", "r1187glance"
|
||||
DOM = os.environ["CTL_DOMAIN"]
|
||||
|
||||
|
||||
def say(*a):
|
||||
print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True)
|
||||
|
||||
|
||||
def guest(cmd):
|
||||
r = subprocess.run(["ssh", "demo-hp", f"export LC_ALL=C; pct exec 9202 -- bash -c {json.dumps(cmd)}"],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
return (r.stdout + r.stderr).strip()
|
||||
|
||||
|
||||
def observe(tag):
|
||||
say(f"[{tag}] gate file:", guest("find / -xdev -name 'family-gate-glance.yml' 2>/dev/null | head -3; true") or "(none)")
|
||||
hdr = subprocess.run(["curl", "-sk", "-o", "/dev/null", "-D", "-", "-H", f"Host: {SUB}.{DOM}", ctl.BASE + "/"],
|
||||
capture_output=True, text=True).stdout.splitlines()
|
||||
out = " ".join([hdr[0].split()[1] if hdr else "000"] + [l.split(" ", 1)[1].strip() for l in hdr if l.lower().startswith("location")])
|
||||
say(f"[{tag}] a stranger's GET https://{SUB}.{DOM}/ ->", out)
|
||||
|
||||
|
||||
def wait_stack(pred, n=60):
|
||||
for _ in range(n):
|
||||
code, d = ctl.call("GET", f"/api/stacks/{APP}")
|
||||
st = (d.get("data") or {}) if isinstance(d, dict) else {}
|
||||
if pred(st):
|
||||
return st
|
||||
time.sleep(5)
|
||||
return st
|
||||
|
||||
|
||||
def poll(path, n=120):
|
||||
last = None
|
||||
for _ in range(n):
|
||||
code, d = ctl.call("GET", path)
|
||||
dd = (d.get("data") or d) if isinstance(d, dict) else {}
|
||||
s = json.dumps(dd, ensure_ascii=False)[:400]
|
||||
if s != last:
|
||||
say(" ", path, s)
|
||||
last = s
|
||||
if isinstance(dd, dict) and dd.get("status") in ("completed", "failed", "done", "error") or (
|
||||
isinstance(dd, dict) and dd.get("running") is False and dd.get("status") not in ("running", "pending")):
|
||||
return dd
|
||||
time.sleep(3)
|
||||
return dd
|
||||
|
||||
|
||||
say("== 1 deploy glance")
|
||||
code, d = ctl.call("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": DOM, "SUBDOMAIN": SUB}})
|
||||
say("deploy ->", code, str(d)[:150])
|
||||
st = wait_stack(lambda s: s.get("deployed") and s.get("state") in ("running", "unhealthy", "degraded"))
|
||||
say("state:", st.get("state"), "family_gate in app_config:", bool((st.get("app_config") or {}).get("family_gate")))
|
||||
time.sleep(20)
|
||||
observe("1 POSITIVE CONTROL after install")
|
||||
|
||||
say("== 2 export to the data drive")
|
||||
drive = "/mnt/felhom-drives/scratch_hdd"
|
||||
code, d = ctl.call("POST", "/api/export/start", {"stack_name": APP, "dest_drive": drive, "password": "", "stop_app": True})
|
||||
say("export start ->", code, str(d)[:200])
|
||||
est = poll("/api/export/status")
|
||||
code, d = ctl.call("GET", "/api/export/bundles")
|
||||
bundles = [b for b in ((d.get("data") or {}).get("bundles") or d.get("data") or []) if isinstance(b, dict) and APP in json.dumps(b)]
|
||||
say("bundles for glance:", json.dumps(bundles, ensure_ascii=False)[:500])
|
||||
path = (bundles[-1].get("path") if bundles else None) or (est.get("output_path") if isinstance(est, dict) else None) # the export's own status (the hunt's harness slip)
|
||||
say("bundle path:", path)
|
||||
|
||||
say("== 3 remove glance (keep nothing)")
|
||||
ctl.call("POST", f"/api/stacks/{APP}/stop")
|
||||
time.sleep(5)
|
||||
code, d = ctl.call("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": True, "remove_backups": True})
|
||||
say("remove ->", code, str(d)[:160])
|
||||
time.sleep(10)
|
||||
observe("3 after remove")
|
||||
|
||||
say("== 4 import the bundle")
|
||||
code, d = ctl.call("POST", "/api/export/import", {"path": path, "password": ""})
|
||||
say("import start ->", code, str(d)[:200])
|
||||
poll("/api/export/import/status")
|
||||
st = wait_stack(lambda s: s.get("deployed") and s.get("state") in ("running", "unhealthy", "degraded"), n=40)
|
||||
say("state after import:", st.get("state"), "family_gate in app_config:", bool((st.get("app_config") or {}).get("family_gate")),
|
||||
"app_config keys:", sorted((st.get("app_config") or {}).keys()))
|
||||
time.sleep(30)
|
||||
observe("4 AFTER import (fixed: must be 401, gate file present)")
|
||||
say("controller log:", guest("docker logs --since 15m felhom-controller 2>&1 | grep -iE 'family|import' | grep -v 'GET /api' | tail -15"))
|
||||
|
||||
say("== 5 teardown: remove glance through the product, delete the bundle")
|
||||
ctl.call("POST", f"/api/stacks/{APP}/stop")
|
||||
time.sleep(5)
|
||||
code, d = ctl.call("POST", f"/api/stacks/{APP}/remove", {"remove_hdd_data": True, "remove_backups": True})
|
||||
say("remove ->", code, str(d)[:160])
|
||||
if path:
|
||||
say("bundle delete:", guest(f"rm -f {json.dumps(path)} && echo deleted {json.dumps(path)}"))
|
||||
time.sleep(10)
|
||||
observe("5 after teardown")
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install7.py — the rehearsal's other seven apps, installed one by one through the install API (the request the install
|
||||
page sends), each waited on until running. Passwords from env names (under `sec run`); nothing secret is printed."""
|
||||
import json, os, sys, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctl
|
||||
B = "/mnt/felhom-drives/belso"
|
||||
E = os.environ
|
||||
apps = [
|
||||
("immich", {"SUBDOMAIN": "photos", "HDD_PATH": B}),
|
||||
("paperless-ngx", {"SUBDOMAIN": "paperless", "HDD_PATH": B, "PAPERLESS_ADMIN_USER": "irisz", "PAPERLESS_ADMIN_PASSWORD": E["REHEARSAL_2026_10_20_IRISZ_PW"], "PAPERLESS_OCR_LANGUAGE": "hun+eng"}),
|
||||
("vaultwarden", {"SUBDOMAIN": "vault", "SIGNUPS_ALLOWED": "true"}),
|
||||
("mealie", {"SUBDOMAIN": "recipes", "ADMIN_PASSWORD": E["REHEARSAL_2026_10_20_KATA_PW"]}),
|
||||
("actualbudget", {"SUBDOMAIN": "budget"}),
|
||||
("radicale", {"SUBDOMAIN": "calendar", "RADICALE_USER": "csalad", "RADICALE_PASSWORD": E["REHEARSAL_2026_10_20_APP_RADICALE"]}),
|
||||
("bookstack", {"SUBDOMAIN": "wiki", "ADMIN_PASSWORD": E["REHEARSAL_2026_10_20_APP_BOOKSTACK"]}),
|
||||
]
|
||||
def say(*a): print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True)
|
||||
for name, vals in apps:
|
||||
t0 = time.time()
|
||||
c, b = ctl.call("POST", f"/api/stacks/{name}/deploy", {"stack_name": name, "values": vals})
|
||||
say(name, "deploy", c, (b.get("message") or b.get("error") or "")[:200] if isinstance(b, dict) else b)
|
||||
if c not in ("200", "202"):
|
||||
continue
|
||||
while time.time() - t0 < 900:
|
||||
time.sleep(10)
|
||||
c2, d = ctl.call("GET", f"/api/stacks/{name}")
|
||||
dd = (d.get("data") or {}) if isinstance(d, dict) else {}
|
||||
if not dd.get("deploying") and dd.get("state") in ("running", "error", "stopped"):
|
||||
break
|
||||
say(name, "→", dd.get("state"), f"{int(time.time()-t0)} s")
|
||||
c, d = ctl.call("GET", "/api/system/memory")
|
||||
say("memory:", json.dumps(d, ensure_ascii=False)[:400])
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""nc_compare.py — every file under Ödön's „Csalad" folder through Nextcloud's own WebDAV door (the box's LAN address with
|
||||
the Host header; TLS not verified, the rehearsal's tester4.test has no DNS), sha256 against the rehearsal's A5 manifest.
|
||||
Env: REHEARSAL_2026_10_20_ODON_PW (under `sec run`). Prints counts and up to 15 differing names; never a secret."""
|
||||
import base64, hashlib, http.client, os, ssl, sys, unicodedata, urllib.parse, xml.etree.ElementTree as ET
|
||||
IP, HOST = "192.168.0.139", "cloud.tester4.test"
|
||||
MAN = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/rehearsal-2026-10-20/A5-data/MANIFEST.tsv"
|
||||
auth = "Basic " + base64.b64encode(("odon:" + os.environ["REHEARSAL_2026_10_20_ODON_PW"]).encode()).decode()
|
||||
ctx = ssl._create_unverified_context()
|
||||
def req(method, path, body=None, extra=None):
|
||||
c = http.client.HTTPSConnection(IP, 443, context=ctx, timeout=300)
|
||||
h = {"Host": HOST, "Authorization": auth}; h.update(extra or {})
|
||||
c.request(method, path, body=body, headers=h); r = c.getresponse(); data = r.read(); c.close()
|
||||
return r.status, data
|
||||
base = "/remote.php/dav/files/odon"
|
||||
files = {}
|
||||
def walk(path):
|
||||
st, data = req("PROPFIND", urllib.parse.quote(base + path), b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>', {"Depth": "1"})
|
||||
if st != 207:
|
||||
print("PROPFIND", path, st); return
|
||||
for resp in ET.fromstring(data).findall("{DAV:}response"):
|
||||
href = urllib.parse.unquote(resp.find("{DAV:}href").text)
|
||||
rel = href[len(base):]
|
||||
if rel.rstrip("/") == path.rstrip("/"):
|
||||
continue
|
||||
if resp.find(".//{DAV:}collection") is not None:
|
||||
walk(rel)
|
||||
else:
|
||||
files[rel] = href
|
||||
walk("/Csalad/")
|
||||
want = {}
|
||||
for line in open(MAN, encoding="utf-8"):
|
||||
p = line.rstrip("\n").split("\t")
|
||||
if p[0].startswith("nextcloud/Csalad/"):
|
||||
want[unicodedata.normalize("NFC", "/" + p[0][len("nextcloud/"):])] = p[2]
|
||||
same, differ, extra = 0, [], []
|
||||
got = {}
|
||||
for rel, href in files.items():
|
||||
st, data = req("GET", urllib.parse.quote(href))
|
||||
got[unicodedata.normalize("NFC", rel)] = hashlib.sha256(data).hexdigest() if st == 200 else f"HTTP{st}"
|
||||
for k, h in want.items():
|
||||
if got.get(k) == h:
|
||||
same += 1
|
||||
else:
|
||||
differ.append(("MISSING" if k not in got else "DIFFERS") + " " + k)
|
||||
extra = [k for k in got if k not in want]
|
||||
print(f"nextcloud Csalad: manifest {len(want)} files; on the box {len(got)}; byte-identical {same}; missing/different {len(differ)}; extra {len(extra)}")
|
||||
for d in differ[:15]:
|
||||
print(" ", d)
|
||||
for e in extra[:8]:
|
||||
print(" EXTRA", e)
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env python3
|
||||
"""offrestore.py APP — the restore wizard's „Teljes visszaállítás" for one app, endpoint-level (the form POSTs the
|
||||
wizard sends): 1) /backup/offbox/restore mode=full (size check), 2) the same with confirm=1 (download to the scratch),
|
||||
3) /backup/offbox/reconstitute confirm=1 (files + database into the live app). Waits on /api/backup/restore-status
|
||||
after 2 and 3. Prints redirects (flash keys) and the status lines only."""
|
||||
import json, os, subprocess, sys, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctl
|
||||
app = sys.argv[1]
|
||||
def say(*a): print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True)
|
||||
def post(path, fields):
|
||||
if ctl._SESS is None:
|
||||
ctl.login()
|
||||
args = ["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", ctl.HOST, "-H", "Cookie: " + ctl._SESS, "-X", "POST",
|
||||
"--data-urlencode", "_csrf=" + ctl._CSRF]
|
||||
for k, v in fields.items():
|
||||
args += ["--data-urlencode", f"{k}={v}"]
|
||||
out = subprocess.run(args + [ctl.BASE + path], capture_output=True, text=True, timeout=300).stdout
|
||||
loc = [l for l in out.splitlines() if l.lower().startswith("location")]
|
||||
say("POST", path, fields, "->", out.splitlines()[0] if out else "?", loc[:1])
|
||||
def wait():
|
||||
t0, last = time.time(), None
|
||||
while time.time() - t0 < 3600:
|
||||
c, d = ctl.call("GET", "/api/backup/restore-status", timeout=90)
|
||||
dd = (d.get("data") or {}) if isinstance(d, dict) else {}
|
||||
s = json.dumps(dd, ensure_ascii=False)[:600]
|
||||
if s != last:
|
||||
say("status:", s); last = s
|
||||
if not dd.get("running") and time.time() - t0 > 10:
|
||||
return
|
||||
time.sleep(10)
|
||||
ack = "--ack-placement" in sys.argv # the wizard's second confirm: „yes, into a different place than the backup recorded"
|
||||
if "--reconstitute-only" not in sys.argv:
|
||||
post("/backup/offbox/restore", {"app": app, "mode": "full"})
|
||||
post("/backup/offbox/restore", {"app": app, "mode": "full", "confirm": "1"})
|
||||
wait()
|
||||
post("/backup/offbox/reconstitute", dict({"app": app, "confirm": "1"}, **({"ack_placement": "1"} if ack else {})))
|
||||
wait()
|
||||
c, d = ctl.call("GET", f"/api/stacks/{app}")
|
||||
say("app state:", (d.get("data") or {}).get("state"))
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""page.py PATH... — log in (ctl.py; the password by env NAME under `sec run`) and print each page's visible text,
|
||||
whitespace-collapsed, plus whether named phrases appear. Endpoint-level: the same GETs a browser sends."""
|
||||
import html, os, re, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctl
|
||||
ctl.login()
|
||||
for path in sys.argv[1:]:
|
||||
body = ctl._curl(["-L", "-H", "Cookie: " + ctl._SESS, ctl.BASE + path])
|
||||
body = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", body, flags=re.S)
|
||||
txt = re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", body))).strip()
|
||||
print(f"=== {path} ({len(body)} bytes)")
|
||||
print(txt[:2500])
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
# resend_curl.sh TOKEN_ENV — the spent bind link's „Új linket kérek" press, endpoint-level (the form POST). Prints the
|
||||
# answer page's visible text only; the token never reaches a command line or the output.
|
||||
set -euo pipefail
|
||||
T=$(mktemp -d); chmod 700 "$T"; trap 'rm -rf "$T"' EXIT
|
||||
printf 'url = "https://hub.felhom.eu/bind/%s/resend"\n' "${!1}" > "$T/cfg"
|
||||
curl -s -K "$T/cfg" -X POST -o "$T/p" -w "POST %{http_code}\n"
|
||||
python3 - "$T/p" <<'PY'
|
||||
import re,sys,html
|
||||
t=open(sys.argv[1],encoding="utf-8").read(); t=re.sub(r"<(script|style)[^>]*>.*?</\1>","",t,flags=re.S)
|
||||
print("answer:", re.sub(r"\s+"," ",html.unescape(re.sub(r"<[^>]+>"," ",t))).strip()[:500])
|
||||
PY
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""unlock.py [--wrong] — the recovery page's „Mentések feloldása": the same form POST (/recovery/unlock, recovery_code)
|
||||
with the session and CSRF token. The code comes from env REHEARSAL_2026_10_20_RECOVERY_CODE (under `sec run`); with
|
||||
--wrong its last word is replaced (a household's typo). Prints the status, the redirect and the visible message only."""
|
||||
import html, os, re, sys, urllib.parse
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctl
|
||||
code = os.environ["REHEARSAL_2026_10_20_RECOVERY_CODE"].strip()
|
||||
if "--wrong" in sys.argv:
|
||||
w = code.split()
|
||||
w[-1] = "alma" if w[-1] != "alma" else "korte"
|
||||
code = " ".join(w)
|
||||
ctl.login()
|
||||
page = ctl._curl(["-H", "Cookie: " + ctl._SESS, ctl.BASE + "/recovery"])
|
||||
m = re.search(r'name="_csrf" value="([^"]+)"', page) or re.search(r'name="csrf_token" value="([^"]+)"', page)
|
||||
fields = {"recovery_code": code}
|
||||
if m:
|
||||
fields[m.group(0).split('"')[1]] = m.group(1)
|
||||
hdr = os.path.join(ctl._T, "uh")
|
||||
body = ctl._curl(["-D", hdr, "-H", "Cookie: " + ctl._SESS, "-H", "X-CSRF-Token: " + ctl._CSRF, "-X", "POST",
|
||||
"-H", "Content-Type: application/x-www-form-urlencoded", "--data-binary", "@-", ctl.BASE + "/recovery/unlock"],
|
||||
inp=urllib.parse.urlencode(fields), timeout=900)
|
||||
h = open(hdr).read()
|
||||
print("status:", [l for l in h.splitlines() if l.lower().startswith(("http/", "location"))])
|
||||
body = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", body, flags=re.S)
|
||||
print("page:", re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", body))).strip()[:1500])
|
||||
Reference in new issue
Block a user