mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 10:01:11 +01:00
578 lines
25 KiB
Python
578 lines
25 KiB
Python
"""Controllable mock services for the Shelfmark e2e Docker platform.
|
|
|
|
A single Flask app that plays one of several *roles*, selected by the
|
|
``MOCK_ROLE`` environment variable. Running one image with different roles keeps
|
|
the platform image small and the behaviour in one auditable place.
|
|
|
|
Roles
|
|
-----
|
|
``origin-aa`` Fake Anna's Archive: search results table, ``/md5/<id>`` detail
|
|
pages, and a downloadable book file. The HTML mirrors the real
|
|
selectors the parser depends on (``<tr>`` rows, last-cell
|
|
distant path, ``get.php?md5=..&key=..`` GET links) so parser
|
|
drift (#878/#879/#880) is caught here. Supports fault injection
|
|
via query flags to reproduce historical bugs deterministically.
|
|
``cloudflare`` Cloudflare-protected origin: returns a 403 "Just a moment..."
|
|
challenge page (with ``cf-mitigated: challenge``) until the
|
|
request carries a ``cf_clearance`` cookie, then serves the real
|
|
content. Exercises the app's CF detection + bypasser routing
|
|
(#284, #226, #202, #1030) without running real CF JS.
|
|
``flaresolverr`` Mock FlareSolverr implementing the ``/v1`` contract. It fetches
|
|
the requested URL *with* a clearance cookie and returns the
|
|
solved HTML + cookies, so ``_fetch_via_bypasser`` runs end to
|
|
end deterministically (no headless Chrome needed in CI).
|
|
``doh`` Minimal DNS-over-HTTPS (RFC 8484 + Google JSON) responder used
|
|
to verify the USE_DOH path resolves mock domains even when the
|
|
system resolver is poisoned (#1028).
|
|
``all`` Mounts every role at once (default; handy for local poking).
|
|
|
|
Fault injection (origin-aa) rides INSIDE the search query as an
|
|
``E2EINJECT:<name>`` token, because the app builds the AA URL itself and only
|
|
forwards the user query as ``q=``. The harness embeds it (see
|
|
PlatformClient.direct_search); the mock strips it before rendering:
|
|
``no_files`` -> renders the literal "No files found." alongside a real row
|
|
(regression for the false-positive check).
|
|
``empty`` -> renders an empty results page (true "No files found").
|
|
``layout_drift`` -> renders a structurally-changed page (cards, no <table>) so
|
|
a hardcoded-index parser yields zero rows — the app must
|
|
fail loudly, not silently (#878/#879/#880).
|
|
``500`` -> returns HTTP 500 (mirror failover path).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
import re
|
|
import struct
|
|
from pathlib import Path
|
|
|
|
from flask import Flask, Response, jsonify, make_response, request
|
|
|
|
FIXTURES = Path(__file__).parent / "fixtures"
|
|
ROLE = os.environ.get("MOCK_ROLE", "all").strip().lower()
|
|
# Hostname the flaresolverr/cloudflare roles use to reach the AA origin from
|
|
# inside the compose network.
|
|
ORIGIN_INTERNAL_URL = os.environ.get("ORIGIN_INTERNAL_URL", "http://mock-aa")
|
|
CLEARANCE_COOKIE = "cf_clearance"
|
|
CLEARANCE_VALUE = "e2e-cleared-token"
|
|
|
|
# Test book: *Moby-Dick* by Herman Melville (public domain), used both as the
|
|
# webseed-torrent payload (qBittorrent path) and the AA slow-download payload (real
|
|
# Chrome / Cloudflare path). PAYLOAD_NAME/URL must stay in sync between what mock-aa
|
|
# serves and what the torrent's url-list references.
|
|
PAYLOAD_NAME = "moby-dick.epub"
|
|
PAYLOAD_URL = f"{os.environ.get('PAYLOAD_PUBLIC_URL', 'http://mock-aa')}/payload/{PAYLOAD_NAME}"
|
|
|
|
# Base for AA slow-download links on the detail page. When set to the Cloudflare
|
|
# gate (the `full` profile sets it to http://cf.mock.test), the *download* — not the
|
|
# search — is forced through the gate, so a real download triggers the internal
|
|
# Chrome bypasser to solve the challenge. Empty -> same-origin (no CF).
|
|
SLOW_DOWNLOAD_BASE = os.environ.get("SLOW_DOWNLOAD_BASE", "").rstrip("/")
|
|
|
|
# Real public-domain opening of Moby-Dick (Chapter 1, "Loomings").
|
|
_MOBY_DICK_TEXT = (
|
|
"Call me Ishmael. Some years ago—never mind how long precisely—having little "
|
|
"or no money in my purse, and nothing particular to interest me on shore, I "
|
|
"thought I would sail about a little and see the watery part of the world. It "
|
|
"is a way I have of driving off the spleen and regulating the circulation. "
|
|
"Whenever I find myself growing grim about the mouth; whenever it is a damp, "
|
|
"drizzly November in my soul; whenever I find myself involuntarily pausing "
|
|
"before coffin warehouses, and bringing up the rear of every funeral I meet; "
|
|
"and especially whenever my hypos get such an upper hand of me, that it "
|
|
"requires a strong moral principle to prevent me from deliberately stepping "
|
|
"into the street, and methodically knocking people's hats off—then, I account "
|
|
"it high time to get to sea as soon as I can."
|
|
)
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
def _payload_bytes() -> bytes:
|
|
"""Deterministic *Moby-Dick* EPUB used as the download payload.
|
|
|
|
Determinism matters: the webseed torrent's piece hashes are computed from these
|
|
exact bytes, so any drift between what mock-aa serves and what the torrent
|
|
describes would make qBittorrent never complete. Fixed ZipInfo timestamps keep
|
|
the bytes byte-stable across runs.
|
|
"""
|
|
import io
|
|
import zipfile
|
|
|
|
files = [
|
|
("mimetype", "application/epub+zip"),
|
|
(
|
|
"META-INF/container.xml",
|
|
'<?xml version="1.0"?><container version="1.0" '
|
|
'xmlns="urn:oasis:names:tc:opendocument:xmlns:container">'
|
|
'<rootfiles><rootfile full-path="OEBPS/content.opf" '
|
|
'media-type="application/oebps-package+xml"/></rootfiles></container>',
|
|
),
|
|
(
|
|
"OEBPS/content.opf",
|
|
'<?xml version="1.0"?><package xmlns="http://www.idpf.org/2007/opf" '
|
|
'version="3.0" unique-identifier="id"><metadata '
|
|
'xmlns:dc="http://purl.org/dc/elements/1.1/">'
|
|
'<dc:identifier id="id">e2e-moby-dick</dc:identifier>'
|
|
"<dc:title>Moby-Dick; or, The Whale</dc:title>"
|
|
"<dc:creator>Herman Melville</dc:creator>"
|
|
"<dc:language>en</dc:language></metadata>"
|
|
'<manifest><item id="c1" href="chapter1.xhtml" '
|
|
'media-type="application/xhtml+xml"/></manifest>'
|
|
'<spine><itemref idref="c1"/></spine></package>',
|
|
),
|
|
(
|
|
"OEBPS/chapter1.xhtml",
|
|
'<?xml version="1.0" encoding="utf-8"?>'
|
|
'<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Loomings</title>'
|
|
"</head><body><h1>Chapter 1. Loomings.</h1>"
|
|
# Repeat the opening so the EPUB clears shelfmark's 10 KB minimum-size
|
|
# check on the direct-download path (_MIN_VALID_FILE_SIZE).
|
|
+ ("<p>" + _MOBY_DICK_TEXT + "</p>") * 24
|
|
+ "</body></html>",
|
|
),
|
|
]
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
|
for name, content in files:
|
|
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
zf.writestr(info, content)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _read_fixture(name: str) -> str:
|
|
return (FIXTURES / name).read_text(encoding="utf-8")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Role: origin-aa (fake Anna's Archive)
|
|
# --------------------------------------------------------------------------- #
|
|
def _search_rows() -> str:
|
|
"""One real result row in the exact shape the parser expects."""
|
|
return _read_fixture("aa_search_results.html")
|
|
|
|
|
|
def register_origin_aa(flask_app: Flask) -> None:
|
|
@flask_app.route("/search")
|
|
def aa_search() -> Response:
|
|
query = request.args.get("q", "")
|
|
# Fault injection travels INSIDE the search query (the app builds the AA
|
|
# URL itself and won't forward arbitrary params), via a token the harness
|
|
# embeds: "E2EINJECT:<name> <real query>".
|
|
inject = ""
|
|
match = re.search(r"E2EINJECT:(\w+)", query)
|
|
if match:
|
|
inject = match.group(1)
|
|
query = re.sub(r"E2EINJECT:\w+\s*", "", query).strip()
|
|
if inject == "500":
|
|
return make_response("upstream error", 500)
|
|
if inject == "empty":
|
|
body = _read_fixture("aa_search_empty.html")
|
|
return make_response(body, 200)
|
|
if inject == "no_files":
|
|
# Real row present *and* the "No files found." string — the historical
|
|
# false-positive (#: 'No files found' check). The app must still
|
|
# surface the real row.
|
|
body = _search_rows().replace("<!-- NO_FILES_MARKER -->", "<div>No files found.</div>")
|
|
return make_response(body, 200)
|
|
if inject == "layout_drift":
|
|
return make_response(_read_fixture("aa_search_layout_drift.html"), 200)
|
|
# Echo the query into the title so tests can assert routing worked.
|
|
body = _search_rows().replace("__QUERY__", query or "A Book Title")
|
|
return make_response(body, 200)
|
|
|
|
@flask_app.route("/md5/<book_id>")
|
|
def aa_detail(book_id: str) -> Response:
|
|
# __SLOW_BASE__ controls where the AA "slow partner server" links point.
|
|
# In the `full` profile it's the Cloudflare gate, so the *download* (not the
|
|
# search/detail) is what forces the internal Chrome bypasser to solve CF.
|
|
body = (
|
|
_read_fixture("aa_detail.html")
|
|
.replace("__MD5__", book_id)
|
|
.replace("__SLOW_BASE__", SLOW_DOWNLOAD_BASE)
|
|
)
|
|
return make_response(body, 200)
|
|
|
|
@flask_app.route("/get.php")
|
|
def aa_getphp() -> Response:
|
|
# The actual file download link target (get.php?md5=..&key=..).
|
|
return _serve_book()
|
|
|
|
@flask_app.route("/slow_download/<path:rest>")
|
|
def aa_slow(rest: str) -> Response:
|
|
# The AA "slow partner server" page: an HTML page whose "Download now" link
|
|
# is the final file URL. shelfmark's _extract_slow_download_url parses this.
|
|
# Reached (in the full profile) only after the internal Chrome bypasser
|
|
# solves the Cloudflare gate in front of it.
|
|
del rest
|
|
file_url = f"{os.environ.get('AA_FILE_BASE', 'http://mock-aa')}/file/{PAYLOAD_NAME}"
|
|
# The visible text must exceed the internal bypasser's "still loading"
|
|
# threshold (_LOADING_BODY_LENGTH_MAX = 50 chars of body.innerText) or it
|
|
# never considers the (cleared) page settled and loops until timeout.
|
|
html = (
|
|
"<!doctype html><html><head><title>Download</title></head><body>"
|
|
"<h1>Anna’s Archive — Slow Partner Server</h1>"
|
|
"<p>Your download of <em>Moby-Dick; or, The Whale</em> by Herman Melville "
|
|
"is ready. Use the link below to download the file from this slow partner "
|
|
"server. The connection is slow but free, with no waitlist.</p>"
|
|
"<div class='top-row'>"
|
|
f'<a href="{file_url}" download>\U0001f4da Download now</a>'
|
|
"</div>"
|
|
"<p>Thank you for supporting open access to knowledge.</p>"
|
|
"</body></html>"
|
|
)
|
|
return make_response(html, 200)
|
|
|
|
@flask_app.route(f"/file/{PAYLOAD_NAME}")
|
|
def aa_file() -> Response:
|
|
# Final file URL extracted from the slow-download page.
|
|
return _serve_book()
|
|
|
|
@flask_app.route("/dyn/api/fast_download.json")
|
|
def aa_fast() -> Response:
|
|
md5 = request.args.get("md5", "")
|
|
return jsonify({"download_url": f"{request.host_url.rstrip('/')}/get.php?md5={md5}&key=k"})
|
|
|
|
# --- webseed payload + torrent for the `full` real-client pipeline --------
|
|
@flask_app.route(f"/payload/{PAYLOAD_NAME}")
|
|
def aa_payload() -> Response:
|
|
# Range support is required for transmission's GetRight webseed (it fetches
|
|
# pieces with `Range: bytes=...` and expects 206); libtorrent clients
|
|
# (qBittorrent/deluge) tolerate a plain 200, but transmission does not.
|
|
return _ranged_response(_payload_bytes(), "application/epub+zip")
|
|
|
|
@flask_app.route("/payload.torrent")
|
|
def aa_torrent() -> Response:
|
|
torrent = _build_payload_torrent()
|
|
resp = make_response(torrent)
|
|
resp.headers["Content-Type"] = "application/x-bittorrent"
|
|
resp.headers["Content-Disposition"] = 'attachment; filename="sample-book.torrent"'
|
|
return resp
|
|
|
|
|
|
def _ranged_response(data: bytes, content_type: str) -> Response:
|
|
"""Serve ``data`` honoring a single HTTP Range request (206 + Content-Range).
|
|
|
|
Needed so transmission's webseed (which fetches via ``Range: bytes=...``) can
|
|
download piece by piece. A request without Range gets the full 200 body.
|
|
"""
|
|
total = len(data)
|
|
range_header = request.headers.get("Range", "")
|
|
if range_header.startswith("bytes="):
|
|
first = range_header[len("bytes=") :].split(",", 1)[0]
|
|
start_s, _, end_s = first.partition("-")
|
|
try:
|
|
start = int(start_s) if start_s else 0
|
|
end = int(end_s) if end_s else total - 1
|
|
except ValueError:
|
|
start, end = 0, total - 1
|
|
end = min(end, total - 1)
|
|
start = max(0, min(start, end))
|
|
chunk = data[start : end + 1]
|
|
resp = make_response(chunk, 206)
|
|
resp.headers["Content-Range"] = f"bytes {start}-{end}/{total}"
|
|
else:
|
|
resp = make_response(data)
|
|
resp.headers["Content-Type"] = content_type
|
|
resp.headers["Accept-Ranges"] = "bytes"
|
|
return resp
|
|
|
|
|
|
def _serve_book() -> Response:
|
|
resp = _ranged_response(_payload_bytes(), "application/epub+zip")
|
|
resp.headers["Content-Disposition"] = f'attachment; filename="{PAYLOAD_NAME}"'
|
|
return resp
|
|
|
|
|
|
def _build_payload_torrent() -> bytes:
|
|
"""Webseed .torrent for the deterministic payload, sourced only from mock-aa.
|
|
|
|
Imported lazily so the doh/cloudflare/flaresolverr roles don't need the
|
|
generator module on the path.
|
|
"""
|
|
import importlib.util
|
|
|
|
gen_path = Path(__file__).parent / "make_webseed_torrent.py"
|
|
spec = importlib.util.spec_from_file_location("make_webseed_torrent", gen_path)
|
|
assert spec and spec.loader
|
|
gen = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(gen)
|
|
return gen.build_webseed_torrent(PAYLOAD_NAME, _payload_bytes(), PAYLOAD_URL)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Role: cloudflare (challenge until clearance cookie present)
|
|
# --------------------------------------------------------------------------- #
|
|
def register_cloudflare(flask_app: Flask) -> None:
|
|
@flask_app.route("/", defaults={"path": ""})
|
|
@flask_app.route("/<path:path>")
|
|
def cf_gate(path: str) -> Response:
|
|
if request.cookies.get(CLEARANCE_COOKIE) == CLEARANCE_VALUE:
|
|
# Cleared: proxy the request through to the real AA origin behaviour.
|
|
return _cleared_passthrough(path)
|
|
challenge = _read_fixture("cf_challenge.html")
|
|
resp = make_response(challenge, 403)
|
|
resp.headers["cf-mitigated"] = "challenge"
|
|
resp.headers["Server"] = "cloudflare"
|
|
return resp
|
|
|
|
|
|
def _cleared_passthrough(path: str) -> Response:
|
|
import requests as _rq
|
|
|
|
target = f"{ORIGIN_INTERNAL_URL}/{path}"
|
|
upstream = _rq.get(target, params=request.args, timeout=10)
|
|
resp = make_response(upstream.content, upstream.status_code)
|
|
resp.headers["Content-Type"] = upstream.headers.get("Content-Type", "text/html")
|
|
return resp
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Role: flaresolverr (mock external bypasser, /v1 contract)
|
|
# --------------------------------------------------------------------------- #
|
|
def register_flaresolverr(flask_app: Flask) -> None:
|
|
@flask_app.route("/v1", methods=["POST"])
|
|
def v1() -> Response:
|
|
import requests as _rq
|
|
|
|
payload = request.get_json(silent=True) or {}
|
|
url = payload.get("url", "")
|
|
if not url:
|
|
return jsonify({"status": "error", "message": "missing url"}), 400
|
|
# "Solve" the challenge by fetching with the clearance cookie set.
|
|
upstream = _rq.get(url, cookies={CLEARANCE_COOKIE: CLEARANCE_VALUE}, timeout=15)
|
|
return jsonify(
|
|
{
|
|
"status": "ok",
|
|
"message": "Challenge solved!",
|
|
"solution": {
|
|
"url": url,
|
|
"status": upstream.status_code,
|
|
"response": upstream.text,
|
|
"cookies": [{"name": CLEARANCE_COOKIE, "value": CLEARANCE_VALUE, "domain": ""}],
|
|
"userAgent": "Mozilla/5.0 (e2e-flaresolverr)",
|
|
},
|
|
}
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Role: prowlarr (minimal Prowlarr API for the `full` real-client pipeline)
|
|
# --------------------------------------------------------------------------- #
|
|
# Implements just the endpoints shelfmark's prowlarr client calls, returning one
|
|
# torrent release whose download is mock-aa's webseed .torrent. A real
|
|
# qBittorrent then completes the download over HTTP (no tracker/peer needed).
|
|
AA_INTERNAL_URL = os.environ.get("AA_INTERNAL_URL", "http://mock-aa")
|
|
|
|
|
|
def register_prowlarr(flask_app: Flask) -> None:
|
|
@flask_app.route("/api/v1/system/status")
|
|
def prowlarr_status() -> Response:
|
|
return jsonify({"appName": "Prowlarr", "version": "1.30.0.4000", "instanceName": "e2e"})
|
|
|
|
@flask_app.route("/api/v1/indexer")
|
|
def prowlarr_indexers() -> Response:
|
|
return jsonify(
|
|
[
|
|
{
|
|
"id": 1,
|
|
"name": "Mock Torznab",
|
|
"enable": True,
|
|
"protocol": "torrent",
|
|
"implementation": "Torznab",
|
|
"implementationName": "Generic Torznab",
|
|
"definitionName": "mock-torznab",
|
|
"capabilities": {
|
|
"categories": [
|
|
{"id": 7000, "name": "Books"},
|
|
{"id": 7020, "name": "Books/EBook"},
|
|
]
|
|
},
|
|
}
|
|
]
|
|
)
|
|
|
|
@flask_app.route("/api/v1/indexer/<int:indexer_id>/newznab")
|
|
def prowlarr_torznab(indexer_id: int) -> Response:
|
|
del indexer_id
|
|
t = request.args.get("t", "search")
|
|
if t == "caps":
|
|
return Response(_torznab_caps(), mimetype="application/xml")
|
|
query = request.args.get("q", "") or "E2E Mock Book"
|
|
return Response(_torznab_search(query), mimetype="application/xml")
|
|
|
|
|
|
def _torznab_caps() -> str:
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
'<caps><server title="Mock Torznab"/>'
|
|
'<limits max="100" default="50"/>'
|
|
'<searching><search available="yes" supportedParams="q"/>'
|
|
'<book-search available="yes" supportedParams="q,author,title"/></searching>'
|
|
'<categories><category id="7000" name="Books">'
|
|
'<subcat id="7020" name="EBook"/></category></categories></caps>'
|
|
)
|
|
|
|
|
|
def _torznab_search(query: str) -> str:
|
|
torrent_url = f"{AA_INTERNAL_URL}/payload.torrent"
|
|
size = len(_payload_bytes())
|
|
title = f"{query} - E2E Mock Book"
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
|
'<rss version="2.0" xmlns:torznab="http://torznab.com/schemas/2015/feed">'
|
|
"<channel>"
|
|
"<item>"
|
|
f"<title>{title}</title>"
|
|
"<guid>e2e-mock-release-1</guid>"
|
|
f"<link>{torrent_url}</link>"
|
|
f"<size>{size}</size>"
|
|
"<pubDate>Mon, 01 Jan 2024 00:00:00 +0000</pubDate>"
|
|
f'<enclosure url="{torrent_url}" length="{size}" type="application/x-bittorrent"/>'
|
|
'<torznab:attr name="category" value="7020"/>'
|
|
'<torznab:attr name="seeders" value="10"/>'
|
|
'<torznab:attr name="peers" value="11"/>'
|
|
'<torznab:attr name="downloadvolumefactor" value="0"/>'
|
|
'<torznab:attr name="uploadvolumefactor" value="1"/>'
|
|
"</item>"
|
|
"</channel></rss>"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Role: doh (DNS over HTTPS responder)
|
|
# --------------------------------------------------------------------------- #
|
|
# Maps mock hostnames to the in-network IP the test wants them resolved to.
|
|
# Provided via DOH_MAP env: "host=ip,host2=ip2".
|
|
def _doh_map() -> dict[str, str]:
|
|
raw = os.environ.get("DOH_MAP", "")
|
|
out: dict[str, str] = {}
|
|
for pair in raw.split(","):
|
|
pair = pair.strip()
|
|
if "=" in pair:
|
|
host, ip = pair.split("=", 1)
|
|
out[host.strip().rstrip(".").lower()] = ip.strip()
|
|
return out
|
|
|
|
|
|
def _encode_a_answer(name: str, ip: str) -> bytes:
|
|
parts = ip.split(".")
|
|
return struct.pack("!HHHIH4B", 0xC00C, 1, 1, 60, 4, *(int(p) for p in parts))
|
|
|
|
|
|
def register_doh(flask_app: Flask) -> None:
|
|
@flask_app.route("/dns-query", methods=["GET", "POST"])
|
|
@flask_app.route("/resolve", methods=["GET", "POST"]) # Google-style JSON endpoint
|
|
def dns_query() -> Response:
|
|
mapping = _doh_map()
|
|
# Google/Cloudflare JSON form (?name=&type=A)
|
|
name = (request.args.get("name") or "").rstrip(".").lower()
|
|
if name:
|
|
ip = mapping.get(name)
|
|
answer = [{"name": name, "type": 1, "TTL": 60, "data": ip}] if ip else []
|
|
return jsonify({"Status": 0 if ip else 3, "Answer": answer})
|
|
# RFC 8484 wireformat (POST body or ?dns=)
|
|
if request.method == "POST":
|
|
wire = request.get_data()
|
|
else:
|
|
dns_b64 = request.args.get("dns", "")
|
|
wire = base64.urlsafe_b64decode(dns_b64 + "=" * (-len(dns_b64) % 4))
|
|
return _wireformat_response(wire, mapping)
|
|
|
|
def _wireformat_response(wire: bytes, mapping: dict[str, str]) -> Response:
|
|
# Minimal parser: echo header/question, append one A answer if known.
|
|
txid = wire[0:2]
|
|
qname, _ = _parse_qname(wire, 12)
|
|
question = wire[12:]
|
|
host = qname.rstrip(".").lower()
|
|
ip = mapping.get(host)
|
|
ancount = 1 if ip else 0
|
|
header = txid + struct.pack("!HHHHH", 0x8180, 1, ancount, 0, 0)
|
|
body = question + (_encode_a_answer(host, ip) if ip else b"")
|
|
resp = make_response(header + body)
|
|
resp.headers["Content-Type"] = "application/dns-message"
|
|
return resp
|
|
|
|
def _parse_qname(wire: bytes, offset: int) -> tuple[str, int]:
|
|
labels = []
|
|
while True:
|
|
length = wire[offset]
|
|
offset += 1
|
|
if length == 0:
|
|
break
|
|
labels.append(wire[offset : offset + length].decode("ascii", "ignore"))
|
|
offset += length
|
|
return ".".join(labels), offset
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Health + role wiring
|
|
# --------------------------------------------------------------------------- #
|
|
@app.route("/healthz")
|
|
def healthz() -> Response:
|
|
return jsonify({"role": ROLE, "ok": True})
|
|
|
|
|
|
_ROLES = {
|
|
"origin-aa": register_origin_aa,
|
|
"cloudflare": register_cloudflare,
|
|
"flaresolverr": register_flaresolverr,
|
|
"prowlarr": register_prowlarr,
|
|
"doh": register_doh,
|
|
}
|
|
|
|
if ROLE == "all":
|
|
for _register in _ROLES.values():
|
|
_register(app)
|
|
elif ROLE in _ROLES:
|
|
_ROLES[ROLE](app)
|
|
else: # pragma: no cover - misconfiguration guard
|
|
raise SystemExit(f"Unknown MOCK_ROLE={ROLE!r}; expected one of {[*sorted(_ROLES), 'all']}")
|
|
|
|
|
|
def _self_signed_cert() -> tuple[str, str]:
|
|
"""Write a throwaway self-signed cert/key to /tmp and return their paths.
|
|
|
|
Used only by the `doh` role's HTTPS server. The app reaches it with
|
|
CERTIFICATE_VALIDATION=disabled, so the cert's identity is irrelevant.
|
|
"""
|
|
import datetime
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.x509.oid import NameOID
|
|
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "e2e-doh")])
|
|
now = datetime.datetime.now(datetime.UTC)
|
|
cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(name)
|
|
.issuer_name(name)
|
|
.public_key(key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(now - datetime.timedelta(days=1))
|
|
.not_valid_after(now + datetime.timedelta(days=3650))
|
|
.add_extension(x509.SubjectAlternativeName([x509.DNSName("cloudflare-dns.com")]), False)
|
|
.sign(key, hashes.SHA256())
|
|
)
|
|
cert_path, key_path = "/tmp/doh.crt", "/tmp/doh.key"
|
|
Path(cert_path).write_bytes(cert.public_bytes(serialization.Encoding.PEM))
|
|
Path(key_path).write_bytes(
|
|
key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
serialization.NoEncryption(),
|
|
)
|
|
)
|
|
return cert_path, key_path
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if os.environ.get("DOH_TLS") == "1":
|
|
cert_path, key_path = _self_signed_cert()
|
|
app.run(host="0.0.0.0", port=443, ssl_context=(cert_path, key_path), threaded=True)
|
|
else:
|
|
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "80")))
|