mirror of
https://github.com/shadowsocks/ShadowsocksX-NG.git
synced 2026-11-03 17:43:44 +00:00
367 lines
11 KiB
Python
Executable File
367 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Convert Loyalsoldier/domain-list-custom geosite.dat geolocation-cn into an
|
|
NG2 rule snapshot.
|
|
|
|
Parses the geosite protobuf typed entries (Plain/Regex/Domain/Full), applies
|
|
`.cn` suffix absorption, and writes a normalized RuleSnapshot JSON plus loss
|
|
report. Failure leaves any previous snapshot untouched.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import hashlib
|
|
import json
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
CONVERTER_VERSION = "2.0.0"
|
|
SCHEMA_VERSION = 2
|
|
|
|
# v2ray geosite Domain.Type
|
|
TYPE_PLAIN = 0
|
|
TYPE_REGEX = 1
|
|
TYPE_DOMAIN = 2
|
|
TYPE_FULL = 3
|
|
|
|
TYPE_NAMES = {
|
|
TYPE_PLAIN: "plain",
|
|
TYPE_REGEX: "regex",
|
|
TYPE_DOMAIN: "domain",
|
|
TYPE_FULL: "full",
|
|
}
|
|
|
|
|
|
def read_varint(data: bytes, offset: int) -> tuple[int, int]:
|
|
result = 0
|
|
shift = 0
|
|
while offset < len(data):
|
|
byte = data[offset]
|
|
offset += 1
|
|
result |= (byte & 0x7F) << shift
|
|
if byte < 0x80:
|
|
return result, offset
|
|
shift += 7
|
|
if shift > 63:
|
|
raise ValueError("varint too long")
|
|
raise ValueError("truncated varint")
|
|
|
|
|
|
def parse_key(data: bytes, offset: int) -> tuple[int, int, int]:
|
|
key, offset = read_varint(data, offset)
|
|
field = key >> 3
|
|
wire = key & 0x07
|
|
return field, wire, offset
|
|
|
|
|
|
def skip_field(data: bytes, offset: int, wire: int) -> int:
|
|
if wire == 0:
|
|
_, offset = read_varint(data, offset)
|
|
return offset
|
|
if wire == 1:
|
|
return offset + 8
|
|
if wire == 2:
|
|
length, offset = read_varint(data, offset)
|
|
return offset + length
|
|
if wire == 5:
|
|
return offset + 4
|
|
raise ValueError(f"unsupported wire type {wire}")
|
|
|
|
|
|
def parse_domain(data: bytes) -> dict:
|
|
"""GeoSite.Domain: type=1 (varint), value=2 (string), attribute=3 (message)."""
|
|
entry = {"type": TYPE_DOMAIN, "value": "", "attributes": []}
|
|
offset = 0
|
|
while offset < len(data):
|
|
field, wire, offset = parse_key(data, offset)
|
|
if field == 1 and wire == 0:
|
|
entry["type"], offset = read_varint(data, offset)
|
|
elif field == 2 and wire == 2:
|
|
length, offset = read_varint(data, offset)
|
|
entry["value"] = data[offset : offset + length].decode("utf-8", "replace")
|
|
offset += length
|
|
elif field == 3 and wire == 2:
|
|
length, offset = read_varint(data, offset)
|
|
attr = parse_attribute(data[offset : offset + length])
|
|
offset += length
|
|
if attr:
|
|
entry["attributes"].append(attr)
|
|
else:
|
|
offset = skip_field(data, offset, wire)
|
|
return entry
|
|
|
|
|
|
def parse_attribute(data: bytes) -> str:
|
|
"""GeoSite.Domain.Attribute: key=1 (string)."""
|
|
key = ""
|
|
offset = 0
|
|
while offset < len(data):
|
|
field, wire, offset = parse_key(data, offset)
|
|
if field == 1 and wire == 2:
|
|
length, offset = read_varint(data, offset)
|
|
key = data[offset : offset + length].decode("utf-8", "replace")
|
|
offset += length
|
|
else:
|
|
offset = skip_field(data, offset, wire)
|
|
return key
|
|
|
|
|
|
def parse_geosite(data: bytes) -> list[dict]:
|
|
"""GeoSiteList: entry=1 (GeoSite message)."""
|
|
sites: list[dict] = []
|
|
offset = 0
|
|
while offset < len(data):
|
|
field, wire, offset = parse_key(data, offset)
|
|
if field == 1 and wire == 2:
|
|
length, offset = read_varint(data, offset)
|
|
sites.append(parse_geo_site(data[offset : offset + length]))
|
|
offset += length
|
|
else:
|
|
offset = skip_field(data, offset, wire)
|
|
return sites
|
|
|
|
|
|
def parse_geo_site(data: bytes) -> dict:
|
|
"""GeoSite: country_code=1 (string), domain=2 (Domain message)."""
|
|
site = {"code": "", "domains": []}
|
|
offset = 0
|
|
while offset < len(data):
|
|
field, wire, offset = parse_key(data, offset)
|
|
if field == 1 and wire == 2:
|
|
length, offset = read_varint(data, offset)
|
|
site["code"] = data[offset : offset + length].decode("utf-8", "replace")
|
|
offset += length
|
|
elif field == 2 and wire == 2:
|
|
length, offset = read_varint(data, offset)
|
|
site["domains"].append(parse_domain(data[offset : offset + length]))
|
|
offset += length
|
|
else:
|
|
offset = skip_field(data, offset, wire)
|
|
return site
|
|
|
|
|
|
def normalize_ipv4_cidr(value: str) -> str | None:
|
|
if "/" in value:
|
|
addr, prefix_s = value.split("/", 1)
|
|
try:
|
|
prefix = int(prefix_s)
|
|
except ValueError:
|
|
return None
|
|
if not 0 <= prefix <= 32:
|
|
return None
|
|
else:
|
|
addr, prefix = value, 32
|
|
parts = addr.split(".")
|
|
if len(parts) != 4:
|
|
return None
|
|
try:
|
|
octets = [int(p) for p in parts]
|
|
except ValueError:
|
|
return None
|
|
if any(o < 0 or o > 255 for o in octets):
|
|
return None
|
|
host = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3]
|
|
mask = 0 if prefix == 0 else (0xFFFFFFFF << (32 - prefix)) & 0xFFFFFFFF
|
|
network = host & mask
|
|
dotted = ".".join(str((network >> shift) & 0xFF) for shift in (24, 16, 8, 0))
|
|
return f"{dotted}/{prefix}"
|
|
|
|
|
|
def domain_suffix(value: str) -> str | None:
|
|
v = value.strip().lower().lstrip(".")
|
|
if not v or "*" in v or v.endswith("."):
|
|
return None
|
|
if not all(c.isalnum() or c in "-." for c in v):
|
|
return None
|
|
labels = v.split(".")
|
|
if v != "cn" and len(labels) < 2:
|
|
return None
|
|
return v
|
|
|
|
|
|
def domain_exact(value: str) -> str | None:
|
|
v = value.strip().lower()
|
|
if not v or "*" in v or v.startswith(".") or v.endswith("."):
|
|
return None
|
|
if not all(c.isalnum() or c in "-." for c in v):
|
|
return None
|
|
return v
|
|
|
|
|
|
def convert(entries: list[dict]) -> dict:
|
|
report = {
|
|
"convertedCount": 0,
|
|
"absorbedCount": 0,
|
|
"skipped": {},
|
|
"rejected": {},
|
|
}
|
|
rules: list[dict] = []
|
|
originals: dict[tuple[str, str], str] = {}
|
|
|
|
def bump(bucket: str, key: str, n: int = 1) -> None:
|
|
target = report[bucket]
|
|
target[key] = target.get(key, 0) + n
|
|
|
|
for entry in entries:
|
|
etype = entry["type"]
|
|
value = entry["value"]
|
|
if etype == TYPE_PLAIN:
|
|
print(f"keyword: {value}")
|
|
bump("skipped", "keyword")
|
|
continue
|
|
if etype == TYPE_REGEX:
|
|
print(f"regexp: {value}")
|
|
bump("skipped", "regexp")
|
|
continue
|
|
if etype == TYPE_DOMAIN:
|
|
normalized = domain_suffix(value)
|
|
kind = "domainSuffix"
|
|
original = f"domain:{value}"
|
|
elif etype == TYPE_FULL:
|
|
normalized = domain_exact(value)
|
|
kind = "domainExact"
|
|
original = f"full:{value}"
|
|
else:
|
|
bump("skipped", "unknownType")
|
|
continue
|
|
if normalized is None:
|
|
print(f"invalidDomain: {value}")
|
|
bump("rejected", "invalidDomain")
|
|
continue
|
|
originals.setdefault((kind, normalized), original)
|
|
rules.append(
|
|
{
|
|
"action": "direct",
|
|
"match": {"kind": kind, "value": normalized},
|
|
}
|
|
)
|
|
|
|
# Deduplicate by (action, match).
|
|
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
|
|
|
|
# Synthesize `.cn` suffix when absent, then absorb same-action .cn domains.
|
|
cn_key = ("domainSuffix", "cn")
|
|
has_cn = any((r["match"]["kind"], r["match"]["value"]) == cn_key for r in rules)
|
|
if not has_cn:
|
|
rules.insert(
|
|
0,
|
|
{
|
|
"action": "direct",
|
|
"match": {"kind": "domainSuffix", "value": "cn"},
|
|
},
|
|
)
|
|
print("synthesized-cn-suffix")
|
|
|
|
absorbed_count = 0
|
|
kept: list[dict] = []
|
|
for rule in rules:
|
|
kind = rule["match"]["kind"]
|
|
value = rule["match"]["value"]
|
|
if (kind, value) == cn_key:
|
|
kept.append(rule)
|
|
continue
|
|
covered = (kind == "domainSuffix" and value.endswith(".cn") and value != "cn") or (
|
|
kind == "domainExact" and value.endswith(".cn")
|
|
)
|
|
if covered and rule["action"] == "direct":
|
|
print(f"absorbed-by-cn-suffix: {originals[(kind, value)]}")
|
|
absorbed_count += 1
|
|
else:
|
|
kept.append(rule)
|
|
|
|
report["convertedCount"] = len(kept)
|
|
report["absorbedCount"] = absorbed_count
|
|
return {
|
|
"schemaVersion": SCHEMA_VERSION,
|
|
"rules": kept,
|
|
"lossReport": report,
|
|
}
|
|
|
|
|
|
def build_snapshot(
|
|
geosite_path: Path,
|
|
category: str,
|
|
upstream_reference: str,
|
|
license_name: str,
|
|
attribution: str,
|
|
) -> dict:
|
|
raw = geosite_path.read_bytes()
|
|
input_digest = hashlib.sha256(raw).hexdigest()
|
|
sites = parse_geosite(raw)
|
|
match = next((s for s in sites if s["code"].lower() == category.lower()), None)
|
|
if match is None:
|
|
raise SystemExit(f"category {category!r} not found in {geosite_path}")
|
|
|
|
source = {
|
|
"kind": "geolocation-cn",
|
|
"upstreamVersion": upstream_reference,
|
|
"label": "geolocation-cn",
|
|
}
|
|
metadata = {
|
|
"source": source,
|
|
"upstreamReference": upstream_reference,
|
|
"inputDigest": input_digest,
|
|
"fetchedAt": dt.datetime.now(dt.timezone.utc)
|
|
.replace(microsecond=0)
|
|
.isoformat()
|
|
.replace("+00:00", "Z"),
|
|
"converterVersion": CONVERTER_VERSION,
|
|
"license": license_name,
|
|
"attribution": attribution,
|
|
}
|
|
snapshot = convert(match["domains"])
|
|
snapshot["metadata"] = metadata
|
|
return snapshot
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("geosite", type=Path, help="path to geosite.dat")
|
|
parser.add_argument("--category", default="GEOLOCATION-CN")
|
|
parser.add_argument("--upstream", required=True, help="pinned upstream reference")
|
|
parser.add_argument("--license", required=True)
|
|
parser.add_argument("--attribution", required=True)
|
|
parser.add_argument("--out", type=Path, required=True, help="output snapshot.json")
|
|
args = parser.parse_args()
|
|
|
|
snapshot = build_snapshot(
|
|
args.geosite,
|
|
args.category,
|
|
args.upstream,
|
|
args.license,
|
|
args.attribution,
|
|
)
|
|
rules = snapshot["rules"]
|
|
if len(rules) < 100:
|
|
print(f"error: abnormal rule count {len(rules)} (min 100)", file=sys.stderr)
|
|
return 1
|
|
if len(rules) > 200_000:
|
|
print(f"error: abnormal rule count {len(rules)} (max 200000)", file=sys.stderr)
|
|
return 1
|
|
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = args.out.with_suffix(".json.tmp")
|
|
tmp.write_text(json.dumps(snapshot, separators=(",", ":"), sort_keys=True) + "\n", encoding="utf-8")
|
|
tmp.replace(args.out)
|
|
loss = snapshot["lossReport"]
|
|
print(
|
|
f"wrote {args.out}: {loss['convertedCount']} rules, "
|
|
f"{loss['absorbedCount']} absorbed, skipped={loss['skipped']}, "
|
|
f"rejected={loss['rejected']}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|