fix(version): support lightweight backend probes
This commit is contained in:
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -29,12 +30,22 @@ def build_url(base_url: str, path: str, params: dict[str, str] | None = None) ->
|
||||
return f"{base}{path}" + (f"?{query}" if query else "")
|
||||
|
||||
|
||||
def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int) -> str:
|
||||
def fetch_response(
|
||||
base_url: str,
|
||||
path: str,
|
||||
params: dict[str, str] | None,
|
||||
timeout: int,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> tuple[str, dict[str, str]]:
|
||||
url = build_url(base_url, path, params)
|
||||
request = urllib.request.Request(url, headers=headers or {})
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as response:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
status = response.status
|
||||
body = response.read().decode("utf-8", errors="replace")
|
||||
response_headers = {
|
||||
key.lower(): value for key, value in response.headers.items()
|
||||
}
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
raise AssertionError(f"{url} returned HTTP {exc.code}\n{body}") from exc
|
||||
@@ -43,6 +54,11 @@ def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int)
|
||||
|
||||
if status < 200 or status >= 300:
|
||||
raise AssertionError(f"{url} returned HTTP {status}\n{body}")
|
||||
return body, response_headers
|
||||
|
||||
|
||||
def fetch(base_url: str, path: str, params: dict[str, str] | None, timeout: int) -> str:
|
||||
body, _ = fetch_response(base_url, path, params, timeout)
|
||||
return body
|
||||
|
||||
|
||||
@@ -76,6 +92,75 @@ def run_checks(base_url: str, timeout: int, snapshot_dir: Path | None, update: b
|
||||
if health.strip() != "ok":
|
||||
raise AssertionError(f"/healthz returned unexpected body: {health!r}")
|
||||
|
||||
version_page, version_headers = fetch_response(
|
||||
base_url, "/version", None, timeout
|
||||
)
|
||||
if (
|
||||
"<!DOCTYPE html>" not in version_page
|
||||
or "SubConverter-Extended" not in version_page
|
||||
):
|
||||
raise AssertionError("/version did not return the HTML version page")
|
||||
if not version_headers.get("content-type", "").lower().startswith("text/html"):
|
||||
raise AssertionError("/version HTML response has an unexpected content type")
|
||||
|
||||
navigation_page, navigation_headers = fetch_response(
|
||||
base_url,
|
||||
"/version",
|
||||
None,
|
||||
timeout,
|
||||
{
|
||||
"Origin": "https://edgetunnel.example",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
},
|
||||
)
|
||||
if "<!DOCTYPE html>" not in navigation_page:
|
||||
raise AssertionError("/version navigation request did not return HTML")
|
||||
if not navigation_headers.get("content-type", "").lower().startswith(
|
||||
"text/html"
|
||||
):
|
||||
raise AssertionError("/version navigation response has an unexpected content type")
|
||||
|
||||
probe_headers = {
|
||||
"Origin": "https://edgetunnel.example",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
}
|
||||
version_probe, version_probe_headers = fetch_response(
|
||||
base_url, "/version", None, timeout, probe_headers
|
||||
)
|
||||
version_probe_line = version_probe.strip()
|
||||
if not re.fullmatch(
|
||||
r"SubConverter-Extended \S+ backend", version_probe_line
|
||||
):
|
||||
raise AssertionError(
|
||||
f"/version probe returned an unexpected body: {version_probe!r}"
|
||||
)
|
||||
if "subconverter" not in version_probe_line.lower() or "<" in version_probe_line:
|
||||
raise AssertionError("/version probe is not compatible with backend detection")
|
||||
if not version_probe_headers.get("content-type", "").lower().startswith(
|
||||
"text/plain"
|
||||
):
|
||||
raise AssertionError("/version probe response has an unexpected content type")
|
||||
if version_probe_headers.get("access-control-allow-origin") != "*":
|
||||
raise AssertionError("/version probe response is missing the CORS header")
|
||||
if "no-store" not in version_probe_headers.get("cache-control", "").lower():
|
||||
raise AssertionError("/version probe response is missing no-store caching")
|
||||
vary = version_probe_headers.get("vary", "").lower()
|
||||
for header in ("sec-fetch-mode", "sec-fetch-dest", "origin"):
|
||||
if header not in vary:
|
||||
raise AssertionError(f"/version probe Vary header is missing {header}")
|
||||
|
||||
legacy_probe, _ = fetch_response(
|
||||
base_url,
|
||||
"/version",
|
||||
None,
|
||||
timeout,
|
||||
{"Origin": "https://edgetunnel.example"},
|
||||
)
|
||||
if legacy_probe != version_probe:
|
||||
raise AssertionError("/version legacy browser probe response is inconsistent")
|
||||
|
||||
inspect_page = fetch(base_url, "/inspect", None, timeout)
|
||||
if (
|
||||
"Request Inspector" not in inspect_page
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <string>
|
||||
|
||||
#include "handler/settings.h"
|
||||
#include "utils/string.h"
|
||||
#include "version.h"
|
||||
|
||||
namespace {
|
||||
@@ -88,6 +89,32 @@ std::string buildCommitLink(const std::string &build_id) {
|
||||
build_id + "</a>";
|
||||
}
|
||||
|
||||
std::string headerValue(const Request &request, const std::string &name) {
|
||||
auto iter = request.headers.find(name);
|
||||
if (iter == request.headers.end())
|
||||
return "";
|
||||
return trimWhitespace(iter->second, true, true);
|
||||
}
|
||||
|
||||
bool isScriptVersionProbe(const Request &request) {
|
||||
std::string fetch_mode = toLower(headerValue(request, "Sec-Fetch-Mode"));
|
||||
std::string fetch_dest = toLower(headerValue(request, "Sec-Fetch-Dest"));
|
||||
|
||||
if (fetch_mode == "cors" && fetch_dest == "empty")
|
||||
return true;
|
||||
|
||||
return fetch_mode.empty() && fetch_dest.empty() &&
|
||||
!headerValue(request, "Origin").empty();
|
||||
}
|
||||
|
||||
std::string buildPlainVersion() {
|
||||
std::string version = VERSION;
|
||||
std::string build_id = BUILD_ID;
|
||||
if (!build_id.empty())
|
||||
version += "-" + build_id;
|
||||
return "SubConverter-Extended " + version + " backend\n";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace version_page {
|
||||
@@ -102,9 +129,16 @@ std::string faviconLight(Request &, Response &response) {
|
||||
return VERSION_FAVICON_LIGHT;
|
||||
}
|
||||
|
||||
std::string page(Request &, Response &response) {
|
||||
std::string page(Request &request, Response &response) {
|
||||
response.headers["X-Robots-Tag"] =
|
||||
"noindex, nofollow, noarchive, nosnippet, noimageindex";
|
||||
response.headers["Vary"] = "Sec-Fetch-Mode, Sec-Fetch-Dest, Origin";
|
||||
if (isScriptVersionProbe(request)) {
|
||||
response.content_type = "text/plain; charset=utf-8";
|
||||
response.headers["Cache-Control"] = "no-store";
|
||||
return buildPlainVersion();
|
||||
}
|
||||
|
||||
std::string build_id = BUILD_ID;
|
||||
std::string build_date = BUILD_DATE;
|
||||
std::string build_date_display = formatBuildDate(build_date);
|
||||
|
||||
Reference in New Issue
Block a user