ZapretKVN-android/scripts/build-geoip-country.py
loop-uh 41b6c3283f
Some checks failed
Android CI / build (push) Has been cancelled
Серверы: страна по IP из офлайн-таблицы DB-IP, флаг и при скрытых адресах
У пользователя 28 из 30 серверов попадали в «Другие»: имена из подписки
не несут флагов, а при включённом скрытии адресов тег, похожий на адрес,
маскировался целиком и вообще не разбирался.

- Страна и протокол берутся из настоящего тега и адреса; маскируется
  только показываемый текст.
- Офлайн-таблица DB-IP IP to Country Lite (CC BY 4.0, атрибуция в
  notices), assets/geoip/country.bin ~2 МБ, IPv4 и IPv6, служебные
  диапазоны всегда без страны; scripts/build-geoip-country.py и проверка
  хэша в verify-project.sh.
- Имена хостов разрешаются тем же доверенным DoH, что и при подключении
  VPN (по IP-литералам, мимо туннеля): один запрос на хост, не чаще раза
  в 7 суток, без таймеров; кэш в noBackupFilesDir.
- Порядок: флаг в имени → страна по IP → сосед на том же адресе → слова.
- JSON-редактор: кнопки в нижней панели, сообщения над ней — снекбар после
  импорта больше не перекрывает «Validate» и «Сохранить».
- Тест обновлятора дожидается закрытия временной VPN-сессии (закрывается в
  finally проверки), а не проверяет это в тот же миг.

Проверено: юнит-тесты, lint, verify-project.sh, 34/34 инструментальных
теста на эмуляторе API 37.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:07:12 +03:00

324 lines
12 KiB
Python
Executable file

#!/usr/bin/env python3
"""Build the offline IP -> country table packaged with the app.
Source: DB-IP "IP to Country Lite" (CC BY 4.0, https://db-ip.com), a monthly CSV
`https://download.db-ip.com/free/dbip-country-lite-YYYY-MM.csv.gz` with rows
`first_ip,last_ip,CC`. The app uses it only to put a flag on a server row from the
server's own address; it never leaves the device and is refreshed only together with
the APK (no runtime download).
Output (stdlib only, deterministic for a given input):
app/src/main/assets/geoip/country.bin compact binary table, format below
app/src/main/assets/geoip/manifest.json source URL, month, sizes, SHA-256 of both files
Usage:
scripts/build-geoip-country.py # current month, falls back to the previous one
scripts/build-geoip-country.py --month 2026-10
scripts/build-geoip-country.py --input dbip-country-lite-2026-10.csv.gz --month 2026-10
scripts/build-geoip-country.py --check # verify committed asset against manifest only
country.bin, all integers big-endian:
4 magic "ZGC1"
4 u32 month, YYYYMM
1 u8 country count N (1..255)
2*N country codes, ASCII; table index 1..N (index 0 is never written)
4 u32 IPv4 entry count 4 u32 IPv4 stream byte length
4 u32 IPv6 entry count 4 u32 IPv6 stream byte length
IPv4 stream, then IPv6 stream. Each entry: varint(start - previous start), u8 country index.
An entry covers [start, next start). IPv4 start is the full 32-bit address; IPv6 start is
the upper 48 bits (a /48), which is finer than any allocation that matters for a server.
Varint: 7 bits per byte, least significant group first, high bit = continuation.
Unknown ("ZZ") ranges are folded into the preceding country: a lookup of reserved or
private space is rejected by the app before it reaches the table.
"""
from __future__ import annotations
import argparse
import csv
import datetime
import gzip
import hashlib
import ipaddress
import json
from pathlib import Path
import struct
import sys
import time
import urllib.error
import urllib.request
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / "app/src/main/assets/geoip"
BIN = ASSETS / "country.bin"
MANIFEST = ASSETS / "manifest.json"
URL = "https://download.db-ip.com/free/dbip-country-lite-{month}.csv.gz"
MAGIC = b"ZGC1"
V6_SHIFT = 80 # keep the upper 48 bits of an IPv6 address
MAX_BYTES = 3 * 1024 * 1024
def fetch(url: str, attempts: int = 3) -> bytes | None:
"""GET with retries; None when the file does not exist yet (HTTP 404)."""
request = urllib.request.Request(url, headers={"User-Agent": "zapret-kvn-android-geoip"})
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(request, timeout=120) as response:
return response.read()
except urllib.error.HTTPError as error:
if error.code == 404:
return None
if attempt == attempts:
raise SystemExit(f"Cannot download {url}: HTTP {error.code}") from None
except (OSError, urllib.error.URLError) as error:
if attempt == attempts:
raise SystemExit(f"Cannot reach {url} after {attempts} attempts: {error}") from None
time.sleep(5 * attempt)
return None
def previous_month(month: str) -> str:
year, number = map(int, month.split("-"))
year, number = (year - 1, 12) if number == 1 else (year, number - 1)
return f"{year:04d}-{number:02d}"
def varint(value: int) -> bytes:
out = bytearray()
while value >= 0x80:
out.append((value & 0x7F) | 0x80)
value >>= 7
out.append(value)
return bytes(out)
def read_varint(data: bytes, offset: int) -> tuple[int, int]:
result = shift = 0
while True:
byte = data[offset]
offset += 1
result |= (byte & 0x7F) << shift
if not byte & 0x80:
return result, offset
shift += 7
def parse_rows(text_rows) -> tuple[list[tuple[int, str | None]], list[tuple[int, str | None]]]:
"""(start, country-or-None) points for IPv4 and IPv6, sorted, one per input row."""
v4: list[tuple[int, str | None]] = []
v6: list[tuple[int, str | None]] = []
for row in text_rows:
if len(row) < 3:
continue
first, _last, code = row[0].strip(), row[1].strip(), row[2].strip().upper()
country = code if len(code) == 2 and code.isalpha() and code != "ZZ" else None
if ":" in first:
v6.append((int(ipaddress.IPv6Address(first)) >> V6_SHIFT, country))
else:
v4.append((int(ipaddress.IPv4Address(first)), country))
v4.sort(key=lambda item: item[0])
v6.sort(key=lambda item: item[0])
return v4, v6
def fold(points: list[tuple[int, str | None]]) -> list[tuple[int, str]]:
"""Merge neighbours with one country; fold unknown ranges into the previous country."""
out: list[tuple[int, str]] = []
for start, country in points:
if country is None:
continue
if out and out[-1][0] == start:
out[-1] = (start, country)
elif not out or out[-1][1] != country:
out.append((start, country))
# A later entry sharing a start replaced an earlier one; a replacement can equal its predecessor.
merged: list[tuple[int, str]] = []
for start, country in out:
if not merged or merged[-1][1] != country:
merged.append((start, country))
return merged
def encode_stream(entries: list[tuple[int, str]], index: dict[str, int]) -> bytes:
out = bytearray()
previous = 0
for start, country in entries:
out += varint(start - previous)
out.append(index[country])
previous = start
return bytes(out)
def build(v4: list[tuple[int, str]], v6: list[tuple[int, str]], month: str) -> bytes:
codes = sorted({country for _, country in v4} | {country for _, country in v6})
if not 0 < len(codes) <= 255:
raise SystemExit(f"Unexpected country count: {len(codes)}")
index = {code: position for position, code in enumerate(codes, start=1)}
s4 = encode_stream(v4, index)
s6 = encode_stream(v6, index)
year, number = map(int, month.split("-"))
header = MAGIC + struct.pack(">IB", year * 100 + number, len(codes))
header += b"".join(code.encode("ascii") for code in codes)
header += struct.pack(">IIII", len(v4), len(s4), len(v6), len(s6))
return header + s4 + s6
def decode(blob: bytes):
"""Reference decoder used for the self-check; mirrors the Kotlin reader."""
if blob[:4] != MAGIC:
raise SystemExit("Bad magic")
month, count = struct.unpack_from(">IB", blob, 4)
offset = 9
codes = [blob[offset + 2 * i: offset + 2 * i + 2].decode("ascii") for i in range(count)]
offset += 2 * count
n4, b4, n6, b6 = struct.unpack_from(">IIII", blob, offset)
offset += 16
def stream(length: int, entries: int):
data = blob[offset_holder[0]: offset_holder[0] + length]
offset_holder[0] += length
position = start = 0
result = []
for _ in range(entries):
delta, position = read_varint(data, position)
start += delta
result.append((start, codes[data[position] - 1]))
position += 1
if position != length:
raise SystemExit("Stream length mismatch")
return result
offset_holder = [offset]
return month, stream(b4, n4), stream(b6, n6)
def lookup(entries: list[tuple[int, str]], key: int) -> str | None:
low, high, found = 0, len(entries) - 1, None
while low <= high:
mid = (low + high) // 2
if entries[mid][0] <= key:
found = entries[mid][1]
low = mid + 1
else:
high = mid - 1
return found
def self_check(blob: bytes, source_rows: list[list[str]]) -> None:
_month, v4, v6 = decode(blob)
# A /48 that holds ranges of several countries is rounded to one of them by design.
mixed: set[int] = set()
seen: dict[int, str] = {}
for row in source_rows:
if ":" not in row[0]:
continue
key = int(ipaddress.IPv6Address(row[0])) >> V6_SHIFT
country = row[2].upper()
if seen.setdefault(key, country) != country:
mixed.add(key)
end = int(ipaddress.IPv6Address(row[1])) >> V6_SHIFT
if end != key and end not in seen:
seen[end] = country
elif end != key and seen[end] != country:
mixed.add(end)
step = max(1, len(source_rows) // 4000)
checked = 0
for row in source_rows[::step]:
first, last, code = row[0], row[1], row[2].upper()
if code == "ZZ":
continue
for address in (first, last):
ip = ipaddress.ip_address(address)
if ip.version == 4:
got = lookup(v4, int(ip))
else:
key = int(ip) >> V6_SHIFT
if key in mixed:
continue
got = lookup(v6, key)
if got != code:
raise SystemExit(f"Self-check failed: {address} -> {got}, expected {code}")
checked += 1
print(f"Self-check passed on {checked} sampled addresses.")
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def check_committed() -> None:
manifest = json.loads(MANIFEST.read_text())
if sha256(BIN) != manifest["sha256"] or BIN.stat().st_size != manifest["bytes"]:
raise SystemExit("GeoIP asset does not match its manifest")
print(f"GeoIP asset matches manifest ({manifest['month']}, {manifest['bytes']} bytes).")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--month", help="YYYY-MM of the DB-IP file (default: current UTC month, then the previous one)")
parser.add_argument("--input", type=Path, help="already downloaded dbip-country-lite-*.csv.gz (needs --month)")
parser.add_argument("--check", action="store_true", help="only verify the committed asset against the manifest")
args = parser.parse_args()
if args.check:
check_committed()
return 0
if args.input:
if not args.month:
parser.error("--input needs --month")
month, raw, url = args.month, args.input.read_bytes(), URL.format(month=args.month)
else:
month = args.month or datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m")
candidates = [month] if args.month else [month, previous_month(month)]
raw = None
for candidate in candidates:
url = URL.format(month=candidate)
print(f"Downloading {url}")
raw = fetch(url)
if raw is not None:
month = candidate
break
if raw is None:
raise SystemExit(f"DB-IP file not found for: {', '.join(candidates)}")
try:
text = gzip.decompress(raw).decode("utf-8")
except (OSError, EOFError, UnicodeDecodeError) as error:
raise SystemExit(f"Not a gzip CSV: {error}") from None
rows = [row for row in csv.reader(text.splitlines()) if row]
v4_points, v6_points = parse_rows(rows)
if len(v4_points) < 100_000 or len(v6_points) < 50_000:
raise SystemExit(f"Suspiciously small source: {len(v4_points)} IPv4 / {len(v6_points)} IPv6 rows")
v4, v6 = fold(v4_points), fold(v6_points)
blob = build(v4, v6, month)
if len(blob) > MAX_BYTES:
raise SystemExit(f"Asset is {len(blob)} bytes, over the {MAX_BYTES} budget")
self_check(blob, rows)
ASSETS.mkdir(parents=True, exist_ok=True)
BIN.write_bytes(blob)
manifest = {
"version": 1,
"format": "ZGC1",
"database": "DB-IP IP to Country Lite",
"month": month,
"source": url,
"source_sha256": hashlib.sha256(raw).hexdigest(),
"license": "CC-BY-4.0",
"attribution": "IP Geolocation by DB-IP",
"attribution_url": "https://db-ip.com",
"file": "country.bin",
"sha256": hashlib.sha256(blob).hexdigest(),
"bytes": len(blob),
"ipv4_entries": len(v4),
"ipv6_entries": len(v6),
"ipv6_prefix_bits": 128 - V6_SHIFT,
"countries": len({c for _, c in v4} | {c for _, c in v6}),
}
MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n")
print(f"Wrote {BIN.relative_to(ROOT)}: {len(blob)} bytes, {len(v4)} IPv4 + {len(v6)} IPv6 entries, {month}.")
return 0
if __name__ == "__main__":
sys.exit(main())