mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
AUR registration is currently returning a bare nginx 503 server-side, so the three packages (todo-flutter, habit-stack, diet-guard-app) are built and verified but cannot be published yet. This runs the whole thing unattended whenever signup reopens. anubis_gate.py exists because the AUR sits behind Anubis: every HTML request gets a proof-of-work challenge page carrying HTTP *200*, so a plain curl reports 200 for a page that is actually unreachable. That is how the 503 got misdiagnosed twice as a local/browser/IPv6 problem. The module solves the challenge and reports the status of the real page behind it. Fail-closed by construction: each package is built, namcap'd, installed and LAUNCHED before it is pushed, and check_aur_rules() blocks the push on the mechanically checkable submission rules (maintainer line, x86_64, no replaces=, not already in an official repo, .SRCINFO matching the PKGBUILD). Verified by injecting a replaces= violation: the push was blocked and reported. A failing package is skipped; the others still publish. Signup itself stays manual — it needs an email confirmation, and creating accounts is not something this should do on the user's behalf. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aoa1nKWRrAbowz7Hfd3tgL
139 lines
4.5 KiB
Python
139 lines
4.5 KiB
Python
"""Fetch an AUR URL from behind the Anubis anti-bot gate.
|
|
|
|
The AUR sits behind Anubis (https://anubis.techaro.lol/), which answers every
|
|
HTML request with a proof-of-work challenge page carrying HTTP **200**. A bare
|
|
`curl` therefore reports 200 for a page that is actually unreachable, which is
|
|
exactly how "is /register back up?" gets answered wrong.
|
|
|
|
This module solves the challenge and reports the status of the *real* page
|
|
behind it, so the caller can distinguish:
|
|
|
|
* 200 -> genuinely available
|
|
* 503 -> reached the site; that endpoint is disabled server-side
|
|
* other-> whatever the app actually said
|
|
|
|
Exit codes mirror the HTTP status so shell callers can branch on them:
|
|
0 for 200, 1 for anything else, 2 for a local/transport failure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import http.cookiejar
|
|
import json
|
|
import re
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
|
|
BASE = "https://aur.archlinux.org"
|
|
UA = "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
|
|
CHALLENGE_RE = re.compile(
|
|
r'<script id="anubis_challenge" type="application/json">(.*?)</script>',
|
|
re.S,
|
|
)
|
|
PASS_PATH = "/.within.website/x/cmd/anubis/api/pass-challenge"
|
|
# A solve at difficulty 4 takes ~0.01s; this ceiling only guards a server-side
|
|
# difficulty spike, so the poller degrades instead of spinning forever.
|
|
MAX_NONCE = 50_000_000
|
|
|
|
|
|
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
"""Keep redirects visible instead of silently following them."""
|
|
|
|
def redirect_request(self, *_args, **_kwargs): # noqa: D102 - see class doc
|
|
return None
|
|
|
|
|
|
def _opener() -> urllib.request.OpenerDirector:
|
|
jar = http.cookiejar.CookieJar()
|
|
opener = urllib.request.build_opener(
|
|
urllib.request.HTTPCookieProcessor(jar), _NoRedirect
|
|
)
|
|
opener.addheaders = [
|
|
("User-Agent", UA),
|
|
("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"),
|
|
]
|
|
return opener
|
|
|
|
|
|
def _solve(random_data: str, difficulty: int) -> tuple[int, str, float]:
|
|
"""Find a nonce whose sha256 digest starts with `difficulty` zero hex chars."""
|
|
prefix = "0" * difficulty
|
|
started = time.time()
|
|
for nonce in range(MAX_NONCE):
|
|
digest = hashlib.sha256(f"{random_data}{nonce}".encode()).hexdigest()
|
|
if digest.startswith(prefix):
|
|
return nonce, digest, time.time() - started
|
|
raise RuntimeError(f"no nonce found below {MAX_NONCE} at difficulty {difficulty}")
|
|
|
|
|
|
def fetch(path: str, timeout: int = 30) -> tuple[int, str]:
|
|
"""Return (status, body) for `path`, clearing Anubis first if challenged."""
|
|
opener = _opener()
|
|
url = f"{BASE}{path}"
|
|
|
|
try:
|
|
with opener.open(url, timeout=timeout) as resp:
|
|
body = resp.read().decode("utf-8", "replace")
|
|
status = resp.status
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code, exc.read().decode("utf-8", "replace")
|
|
|
|
match = CHALLENGE_RE.search(body)
|
|
if not match:
|
|
return status, body
|
|
|
|
challenge = json.loads(match.group(1))["challenge"]
|
|
nonce, digest, elapsed = _solve(
|
|
challenge["randomData"], int(challenge["difficulty"])
|
|
)
|
|
query = urllib.parse.urlencode(
|
|
{
|
|
"id": challenge["id"],
|
|
"response": digest,
|
|
"nonce": str(nonce),
|
|
"redir": path,
|
|
"elapsedTime": str(int(elapsed * 1000)),
|
|
}
|
|
)
|
|
try:
|
|
opener.open(f"{BASE}{PASS_PATH}?{query}", timeout=timeout)
|
|
except urllib.error.HTTPError:
|
|
# A redirect or non-2xx here is fine; what matters is the cookie the
|
|
# opener's jar just captured.
|
|
pass
|
|
|
|
try:
|
|
with opener.open(url, timeout=timeout) as resp:
|
|
return resp.status, resp.read().decode("utf-8", "replace")
|
|
except urllib.error.HTTPError as exc:
|
|
return exc.code, exc.read().decode("utf-8", "replace")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("path", help="AUR path, e.g. /register")
|
|
parser.add_argument(
|
|
"--print-body", action="store_true", help="write the page body to stdout"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
status, body = fetch(args.path)
|
|
except (urllib.error.URLError, RuntimeError, TimeoutError) as exc:
|
|
print(f"transport error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
print(status)
|
|
if args.print_body:
|
|
sys.stdout.write(body)
|
|
return 0 if status == 200 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|