initial commit

This commit is contained in:
2026-08-16 23:21:59 +00:00
commit 12fa44cbeb
11 changed files with 696 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
__version__ = "1.0.0"
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import argparse
import logging
import sys
from . import dnsmasq_gen, fetch, ip_render, normalize
from .config import Config, ListEntry, load_config
log = logging.getLogger("xray-lists")
def gather_ip_lines(cfg: Config, session, entry: ListEntry) -> set[str]:
text_chunks = []
for url in entry.urls:
text = fetch.fetch_url(session, cfg.global_cfg.cache_dir, entry.id, url,
cfg.global_cfg.http_timeout)
if text is None:
log.warning("%s: no data available for %s (network down, no cache) - skipping this url",
entry.id, url)
continue
text_chunks.append(text)
all_text = "\n".join(text_chunks + entry.static)
return normalize.normalize_ip_lines(all_text, entry.id)
def gather_domain_lines(cfg: Config, session, entry: ListEntry) -> set[str]:
text_chunks = list(entry.static)
for url in entry.urls:
text = fetch.fetch_url(session, cfg.global_cfg.cache_dir, entry.id, url,
cfg.global_cfg.http_timeout)
if text is None:
log.warning("%s: no data available for %s (network down, no cache) - skipping this url",
entry.id, url)
continue
text_chunks.append(text)
return normalize.normalize_domain_lines("\n".join(text_chunks), entry.id)
def process_ip_entry(cfg: Config, session, entry: ListEntry, dry_run: bool) -> bool:
cidrs = gather_ip_lines(cfg, session, entry)
elements = normalize.sort_ips(cidrs)
content = ip_render.render_elements_file(elements)
changed = ip_render.write_if_changed(entry.output, content, dry_run=dry_run)
log.info("%s: %d entries -> %s%s", entry.id, len(elements), entry.output,
" (changed)" if changed else "")
return changed
def process_domain_entries(cfg: Config, session, entries: list[ListEntry],
dry_run: bool) -> bool:
if not entries:
return False
domain_to_targets: dict[str, list[str]] = {}
for entry in entries:
domains = gather_domain_lines(cfg, session, entry)
for d in domains:
domain_to_targets.setdefault(d, []).append(entry.dnsmasq_target)
log.info("%s: %d domains -> %s", entry.id, len(domains), entry.dnsmasq_target)
content = dnsmasq_gen.render_nftset_file(domain_to_targets)
changed = dnsmasq_gen.write_if_changed(cfg.global_cfg.dnsmasq_output, content, dry_run=dry_run)
log.info("dnsmasq nftset file: %d domains total -> %s%s",
len(domain_to_targets), cfg.global_cfg.dnsmasq_output,
" (changed)" if changed else "")
return changed
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="fetch ip/cidr and domain lists and generate files for nftables and dnsmasq"
)
parser.add_argument("--config", default="/var/lib/xray-lists/config.yaml", help="path to config file")
parser.add_argument("--only", help="update only specific list ids (comma-separated)")
parser.add_argument("--dry-run", action="store_true", help="download and process, but do not write files")
parser.add_argument("-v", "--verbose", action="store_true", help="enable verbose logging")
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
cfg = load_config(args.config)
session = fetch.build_session(cfg.global_cfg.proxy, cfg.global_cfg.proxy_user,
cfg.global_cfg.proxy_pass)
only = set(args.only.split(",")) if args.only else None
ip_entries = [e for e in cfg.ip_sets if only is None or e.id in only]
domain_entries = [e for e in cfg.domain_sets if only is None or e.id in only]
changed_files = []
for entry in ip_entries:
if process_ip_entry(cfg, session, entry, args.dry_run):
changed_files.append(str(entry.output))
if process_domain_entries(cfg, session, domain_entries, args.dry_run):
changed_files.append(str(cfg.global_cfg.dnsmasq_output))
if changed_files:
print("changed:")
for f in changed_files:
print(f" {f}")
else:
log.info("nothing changed")
return 0
if __name__ == "__main__":
sys.exit(main())
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from pathlib import Path
import yaml
@dataclass
class GlobalConfig:
cache_dir: Path = Path("/var/lib/xray-lists/cache")
output_dir: Path = Path("/var/lib/xray-lists/generated")
dnsmasq_output: Path = Path("/var/lib/xray-lists/generated/nftsets.conf")
proxy: str | None = None
proxy_user: str | None = None
proxy_pass: str | None = None
http_timeout: int = 20
@dataclass
class ListEntry:
id: str
kind: str
static: list[str] = field(default_factory=list)
urls: list[str] = field(default_factory=list)
output: Path | None = None
dnsmasq_target: str | None = None
def source_label(self) -> str:
parts = []
if self.static:
parts.append(f"{len(self.static)} static")
if self.urls:
parts.append(f"{len(self.urls)} url(s)")
return ", ".join(parts) or "empty"
@dataclass
class Config:
global_cfg: GlobalConfig
ip_sets: list[ListEntry]
domain_sets: list[ListEntry]
def all_entries(self) -> list[ListEntry]:
return self.ip_sets + self.domain_sets
def _die(msg: str) -> None:
print(f"config error: {msg}", file=sys.stderr)
sys.exit(1)
def load_config(path: str | Path) -> Config:
path = Path(path)
if not path.is_file():
_die(f"config file not found: {path}")
with path.open("r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
g_raw = raw.get("global", {}) or {}
gcfg = GlobalConfig(
cache_dir=Path(g_raw.get("cache_dir", GlobalConfig.cache_dir)),
output_dir=Path(g_raw.get("output_dir", GlobalConfig.output_dir)),
dnsmasq_output=Path(g_raw.get("dnsmasq_output", GlobalConfig.dnsmasq_output)),
proxy=g_raw.get("proxy"),
proxy_user=g_raw.get("proxy_user"),
proxy_pass=g_raw.get("proxy_pass"),
http_timeout=int(g_raw.get("http_timeout", GlobalConfig.http_timeout)),
)
seen_ids: set[str] = set()
def parse_ip_entries(raw_list) -> list[ListEntry]:
out = []
for item in raw_list or []:
eid = item.get("id")
if not eid:
_die(f"ip entry missing required 'id': {item}")
if eid in seen_ids:
_die(f"duplicate entry id '{eid}'")
seen_ids.add(eid)
static = item.get("static") or []
urls = item.get("urls") or []
if not static and not urls:
_die(f"entry '{eid}' has neither 'static' nor 'urls' - nothing to do")
output = item.get("output")
if not output:
_die(f"ip entry '{eid}' missing required 'output' (filename or path)")
output_path = Path(output)
if not output_path.is_absolute():
output_path = gcfg.output_dir / output_path
out.append(ListEntry(
id=eid, kind="ip",
static=[str(x) for x in static],
urls=[str(x) for x in urls],
output=output_path,
))
return out
def parse_domain_entries(raw_list) -> list[ListEntry]:
out = []
for item in raw_list or []:
eid = item.get("id")
if not eid:
_die(f"domain entry missing required 'id': {item}")
if eid in seen_ids:
_die(f"duplicate entry id '{eid}'")
seen_ids.add(eid)
static = item.get("static") or []
urls = item.get("urls") or []
if not static and not urls:
_die(f"entry '{eid}' has neither 'static' nor 'urls' - nothing to do")
target = item.get("dnsmasq_target")
if not target:
_die(f"domain entry '{eid}' missing required 'dnsmasq_target' "
f"(e.g. '4#ip#xray#v2ray_dom')")
out.append(ListEntry(
id=eid, kind="domain",
static=[str(x) for x in static],
urls=[str(x) for x in urls],
dnsmasq_target=str(target),
))
return out
ip_sets = parse_ip_entries(raw.get("ip_sets"))
domain_sets = parse_domain_entries(raw.get("domain_sets"))
if not ip_sets and not domain_sets:
_die("config has no ip_sets/domain_sets defined")
return Config(global_cfg=gcfg, ip_sets=ip_sets, domain_sets=domain_sets)
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
import logging
from pathlib import Path
log = logging.getLogger("xray-lists.dnsmasq")
def render_nftset_file(domain_to_targets: dict[str, list[str]]) -> str:
lines = []
for domain in sorted(domain_to_targets):
targets = ",".join(domain_to_targets[domain])
lines.append(f"nftset=/{domain}/{targets}")
return "\n".join(lines) + ("\n" if lines else "")
def write_if_changed(path: Path, content: str, dry_run: bool = False) -> bool:
old = path.read_text() if path.is_file() else None
if old == content:
log.debug("%s: unchanged", path)
return False
if dry_run:
log.info("[dry-run] would write %s", path)
return True
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(content)
tmp.replace(path)
log.info("wrote %s", path)
return True
+99
View File
@@ -0,0 +1,99 @@
"""
Скачивание удалённых списков с ETag-кэшем и фолбэком на локальный кэш
при недоступности источника - логика 1:1 с _fetch_url() из bash-версии.
Отдельная страховка: PySocks (используется при socks5h:// прокси) не
всегда честно соблюдает timeout, переданный в requests.get() - может
зависнуть на этапе SOCKS-хендшейка или чтения ответа. Поэтому вокруг
запроса дополнительно ставится жёсткий wall-clock таймаут через
SIGALRM, чтобы скрипт гарантированно не висел вечно на одном списке.
"""
from __future__ import annotations
import hashlib
import logging
import signal
from contextlib import contextmanager
from pathlib import Path
import requests
log = logging.getLogger("xray-lists.fetch")
class HardTimeout(Exception):
pass
@contextmanager
def _hard_timeout(seconds: int):
def _on_alarm(signum, frame):
raise HardTimeout(f"hard timeout after {seconds}s (possibly a hung proxy)")
previous = signal.signal(signal.SIGALRM, _on_alarm)
signal.alarm(seconds)
try:
yield
finally:
signal.alarm(0)
signal.signal(signal.SIGALRM, previous)
def _url_hash(url: str) -> str:
return hashlib.md5(url.encode()).hexdigest()[:8]
def build_session(proxy: str | None, proxy_user: str | None, proxy_pass: str | None) -> requests.Session:
s = requests.Session()
if proxy:
if proxy_user:
# вставляем креды в URL прокси: scheme://user:pass@host:port
scheme, rest = proxy.split("://", 1)
proxy = f"{scheme}://{proxy_user}:{proxy_pass}@{rest}"
s.proxies.update({"http": proxy, "https": proxy})
return s
def fetch_url(session: requests.Session, cache_dir: Path, entry_id: str, url: str,
timeout: int) -> str | None:
"""
Возвращает текстовое содержимое URL, используя ETag-кэш.
None означает "данных нет вообще" (сеть недоступна и кэша тоже нет).
"""
cache_dir.mkdir(parents=True, exist_ok=True)
h = _url_hash(url)
raw_cache = cache_dir / f"{entry_id}_{h}.raw"
etag_file = cache_dir / f"{entry_id}_{h}.etag"
headers = {}
etag = None
if etag_file.is_file():
etag = etag_file.read_text().strip()
if etag:
headers["If-None-Match"] = f'"{etag}"'
try:
# +5s запас сверх requests-таймаута: если requests/PySocks сами
# отработают штатно, alarm просто снимется в finally и не выстрелит.
with _hard_timeout(timeout + 5):
resp = session.get(url, headers=headers, timeout=timeout)
except (requests.RequestException, HardTimeout) as exc:
log.warning("%s: download failed (%s), using local cache if any", entry_id, exc)
return raw_cache.read_text() if raw_cache.is_file() else None
if resp.status_code == 304:
log.info("%s: 304 not modified (%s)", entry_id, url)
return raw_cache.read_text() if raw_cache.is_file() else None
if resp.status_code == 200:
log.info("%s: 200 ok (%s)", entry_id, url)
new_etag = resp.headers.get("ETag", "").strip().strip('"').lstrip("W/")
if new_etag:
etag_file.write_text(new_etag)
raw_cache.write_text(resp.text)
return resp.text
log.warning("%s: unexpected status %s for %s, using local cache if any",
entry_id, resp.status_code, url)
return raw_cache.read_text() if raw_cache.is_file() else None
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import logging
from pathlib import Path
log = logging.getLogger("xray-lists.render")
def render_elements_file(elements: list[str]) -> str:
if not elements:
return "elements = { }\n"
joined = ",\n ".join(elements)
return f"elements = {{\n {joined}\n}}\n"
def write_if_changed(path: Path, content: str, dry_run: bool = False) -> bool:
old = path.read_text() if path.is_file() else None
if old == content:
log.debug("%s: unchanged", path)
return False
if dry_run:
log.info("[dry-run] would write %s", path)
return True
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(content)
tmp.replace(path)
log.info("wrote %s", path)
return True
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
import ipaddress
import logging
import re
log = logging.getLogger("xray-lists.normalize")
_DOMAIN_RE = re.compile(r"^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$")
def normalize_ip_lines(text: str, source_label: str = "") -> set[str]:
out: set[str] = set()
for raw_line in text.splitlines():
line = raw_line.strip().rstrip("\r")
if not line or line.startswith("#"):
continue
token = line.split()[0]
try:
if "/" in token:
net = ipaddress.ip_network(token, strict=False)
else:
net = ipaddress.ip_network(f"{token}/32", strict=False)
except ValueError:
log.warning("%s: skipping invalid ip/cidr line: %r", source_label, line)
continue
out.add(str(net))
return out
_SKIP_PREFIXES = ("keyword:", "regexp:", "include:")
_STRIP_PREFIXES = ("full:", "domain:")
def normalize_domain_lines(text: str, source_label: str = "") -> set[str]:
out: set[str] = set()
for raw_line in text.splitlines():
line = raw_line.strip().rstrip("\r").lower()
if not line or line.startswith("#"):
continue
token = line.split()[0]
if token.startswith(_SKIP_PREFIXES):
continue
for pfx in _STRIP_PREFIXES:
if token.startswith(pfx):
token = token[len(pfx):]
break
token = token.lstrip(".")
if not _DOMAIN_RE.match(token):
log.warning("%s: skipping invalid domain line: %r", source_label, line)
continue
out.add(token)
return out
def sort_ips(cidrs: set[str]) -> list[str]:
return sorted(cidrs, key=lambda c: ipaddress.ip_network(c))
def sort_domains(domains: set[str]) -> list[str]:
return sorted(domains)