Files
ShadowsocksX-NG/ShadowsocksX-NG2/Scripts/convert-gfwlist.py
T

358 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Convert the official Base64 GFWList AutoProxy list into an NG2 rule snapshot
(issue #65).
Only rules that can be expressed losslessly as a target domain are converted.
URL-path and protocol conditions are never expanded into whole-domain rules.
`@@` exceptions shadowed by a broader proxy rule are kept out of the ACL and
reported item by item; the broader proxy rules stay. Unknown syntax, corrupt
input, or abnormal shrinkage block the update and leave the previous snapshot.
"""
from __future__ import annotations
import argparse
import base64
import binascii
import datetime as dt
import hashlib
import ipaddress
import json
import re
import sys
from pathlib import Path
CONVERTER_VERSION = "1.0.0"
SCHEMA_VERSION = 1
MINIMUM_RULE_COUNT = 100
MAXIMUM_RULE_COUNT = 50_000
MINIMUM_SCALE_RATIO = 0.5
MAXIMUM_SCALE_RATIO = 2.0
DOMAIN_HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$")
class UnknownSyntax(Exception):
def __init__(self, line: str) -> None:
super().__init__(f"unknown AutoProxy syntax: {line}")
self.line = line
def looks_like_ip(text: str) -> bool:
try:
ipaddress.ip_address(text)
return True
except ValueError:
pass
parts = text.split(".")
return len(parts) == 4 and all(p.isdigit() for p in parts)
def is_valid_domain_host(host: str) -> bool:
return bool(host) and "." in host and DOMAIN_HOST_RE.fullmatch(host) is not None
def parse_line(raw_line: str) -> dict:
"""Classify one AutoProxy line. Raises UnknownSyntax on unrecognized forms."""
line = raw_line.strip()
if not line:
return {"kind": "blank"}
if line.startswith("!"):
return {"kind": "comment"}
if line.startswith("[") and line.endswith("]"):
return {"kind": "header"}
is_exception = line.startswith("@@")
pattern = line[2:] if is_exception else line
if pattern.startswith("||"):
return parse_domain_pattern(pattern[2:], line, is_exception)
if pattern.startswith("|"):
return {"kind": "urlPrefix", "original": line, "isException": is_exception}
if len(pattern) >= 3 and pattern.startswith("/") and pattern.endswith("/"):
return {"kind": "regexp", "original": line, "isException": is_exception}
if "*" in pattern:
return {"kind": "wildcard", "original": line, "isException": is_exception}
if looks_like_ip(pattern):
return {"kind": "ipLiteral", "original": line, "isException": is_exception}
if "." in pattern and DOMAIN_HOST_RE.fullmatch(pattern):
return {"kind": "plainText", "original": line, "isException": is_exception}
raise UnknownSyntax(line)
def parse_domain_pattern(host_part: str, original: str, is_exception: bool) -> dict:
if "/" in host_part:
return {"kind": "urlPath", "original": original, "isException": is_exception}
if "*" in host_part:
return {"kind": "wildcard", "original": original, "isException": is_exception}
if "$" in host_part:
return {"kind": "filterOption", "original": original, "isException": is_exception}
host = host_part
if host.endswith("^"):
host = host[:-1]
if "^" in host:
raise UnknownSyntax(original)
if looks_like_ip(host):
return {"kind": "ipLiteral", "original": original, "isException": is_exception}
if "." not in host:
return {
"kind": "singleLabelPrefix",
"original": original,
"isException": is_exception,
}
if not is_valid_domain_host(host):
raise UnknownSyntax(original)
kind = "domainException" if is_exception else "domainProxy"
return {"kind": kind, "host": host.lower(), "original": original}
def domain_suffix_covers(broad: str, narrow: str) -> bool:
broad_host = broad.lower()
narrow_host = narrow.lower()
return narrow_host == broad_host or narrow_host.endswith("." + broad_host)
def split_shadowed(
proxy_rules: list[dict], exception_rules: list[dict]
) -> tuple[list[dict], list[dict], list[tuple[dict, dict]]]:
"""Return (kept_proxy, kept_exceptions, shadowed_pairs).
A proxy rule covers an exception when its domain suffix is equal or broader.
sslocal matches domain proxy_list before bypass_list, so a covered exception
would be an ineffective ACL entry.
"""
kept_exceptions: list[dict] = []
shadowed: list[tuple[dict, dict]] = []
for exception in exception_rules:
blocker = None
for proxy in proxy_rules:
if domain_suffix_covers(
proxy["match"]["value"], exception["match"]["value"]
):
blocker = proxy
break
if blocker is None:
kept_exceptions.append(exception)
else:
marked = dict(exception)
marked["conflict"] = {
"originalEntry": exception["conflict"]["originalEntry"],
"absorbedBy": blocker["match"],
"notes": list(exception["conflict"]["notes"]) + ["shadowed-by-broader-proxy"],
}
shadowed.append((marked, blocker))
return proxy_rules, kept_exceptions, shadowed
def make_rule(action: str, match: dict, original: str, source: dict) -> dict:
return {
"action": action,
"match": match,
"source": source,
"conflict": {"originalEntry": original, "absorbedBy": None, "notes": []},
}
def convert_document(document: str, source: dict) -> tuple[list[dict], list[dict], dict]:
"""Return (rules, absorbed, lossReport)."""
skipped: dict[str, int] = {}
notes: list[str] = []
proxy_rules: list[dict] = []
exception_rules: list[dict] = []
saw_code_line = False
def bump(category: str) -> None:
skipped[category] = skipped.get(category, 0) + 1
for raw_line in document.splitlines():
entry = parse_line(raw_line)
kind = entry["kind"]
if kind == "blank":
bump("blank")
elif kind == "comment":
bump("comment")
elif kind == "header":
bump("header")
elif kind == "domainProxy":
saw_code_line = True
proxy_rules.append(
make_rule(
"proxy",
{"kind": "domainSuffix", "value": entry["host"]},
entry["original"],
source,
)
)
elif kind == "domainException":
saw_code_line = True
exception_rules.append(
make_rule(
"direct",
{"kind": "domainSuffix", "value": entry["host"]},
entry["original"],
source,
)
)
elif kind in {
"urlPrefix",
"urlPath",
"filterOption",
"wildcard",
"regexp",
"plainText",
"singleLabelPrefix",
"ipLiteral",
}:
saw_code_line = True
bump(kind)
notes.append(f"unexpressible-{kind}: {entry['original']}")
else:
raise UnknownSyntax(entry.get("original", raw_line))
if not saw_code_line:
raise SystemExit("empty input")
kept_proxy, kept_exceptions, shadowed = split_shadowed(proxy_rules, exception_rules)
if shadowed:
skipped["shadowedException"] = len(shadowed)
for exception, blocker in shadowed:
notes.append(
"shadowed-exception: "
f"{exception['conflict']['originalEntry']} "
f"shadowed-by {blocker['conflict']['originalEntry']}"
)
rules = kept_proxy + kept_exceptions
# Deduplicate by (action, match.kind, match.value), keeping first.
seen: set[tuple[str, str, str]] = set()
deduped: list[dict] = []
for rule in rules:
key = (rule["action"], rule["match"]["kind"], rule["match"]["value"])
if key in seen:
continue
seen.add(key)
deduped.append(rule)
rules = deduped
report = {
"convertedCount": len(rules),
"absorbedCount": len(shadowed),
"skipped": skipped,
"rejected": {},
"notes": notes,
}
return rules, [item for item, _ in shadowed], report
def build_snapshot(
document: str,
source: dict,
upstream_reference: str,
license_name: str,
attribution: str,
previous_rule_count: int | None,
) -> dict:
rules, absorbed, report = convert_document(document, source)
rule_count = len(rules)
if rule_count < MINIMUM_RULE_COUNT:
raise SystemExit(f"abnormal rule count {rule_count} (min {MINIMUM_RULE_COUNT})")
if rule_count > MAXIMUM_RULE_COUNT:
raise SystemExit(f"abnormal rule count {rule_count} (max {MAXIMUM_RULE_COUNT})")
if previous_rule_count is not None and previous_rule_count > 0:
lower = int(previous_rule_count * MINIMUM_SCALE_RATIO)
upper = int(previous_rule_count * MAXIMUM_SCALE_RATIO)
if not (lower <= rule_count <= upper):
raise SystemExit(
f"abnormal scale change: {rule_count} vs previous {previous_rule_count} "
f"(allowed {lower}..{upper})"
)
return {
"schemaVersion": SCHEMA_VERSION,
"metadata": {
"source": source,
"upstreamReference": upstream_reference,
"inputDigest": hashlib.sha256(document.encode("utf-8")).hexdigest(),
"fetchedAt": dt.datetime.now(dt.timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z"),
"converterVersion": CONVERTER_VERSION,
"license": license_name,
"attribution": attribution,
},
"rules": rules,
"absorbed": absorbed,
"lossReport": report,
}
def decode_autoproxy(text: str) -> str:
compact = "".join(text.split())
try:
raw = base64.b64decode(compact, validate=False)
except (binascii.Error, ValueError) as exc:
raise SystemExit(f"corrupt Base64 input: {exc}") from exc
try:
return raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise SystemExit(f"corrupt AutoProxy payload: {exc}") from exc
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input", type=Path, help="path to official Base64 gfwlist.txt")
parser.add_argument("--upstream", required=True, help="pinned upstream commit")
parser.add_argument(
"--upstream-reference",
required=True,
help="upstream repo/product identifier including the pinned commit or URL",
)
parser.add_argument("--license", required=True)
parser.add_argument("--attribution", required=True)
parser.add_argument("--out", type=Path, required=True, help="output snapshot.json")
parser.add_argument(
"--previous-count",
type=int,
default=None,
help="previous snapshot rule count for scale-change detection",
)
args = parser.parse_args()
base64_text = args.input.read_text(encoding="utf-8")
document = decode_autoproxy(base64_text)
source = {
"kind": "gfwlist",
"upstreamVersion": args.upstream,
"label": "GFWList",
}
try:
snapshot = build_snapshot(
document,
source,
args.upstream_reference,
args.license,
args.attribution,
args.previous_count,
)
except UnknownSyntax as exc:
raise SystemExit(str(exc)) from exc
args.out.parent.mkdir(parents=True, exist_ok=True)
tmp = args.out.with_suffix(".json.tmp")
tmp.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
tmp.replace(args.out)
loss = snapshot["lossReport"]
print(
f"wrote {args.out}: {loss['convertedCount']} rules, "
f"skipped={loss['skipped']}, "
f"shadowed={loss['skipped'].get('shadowedException', 0)}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())