diff --git a/patch.py b/patch.py new file mode 100644 index 0000000..4aed277 --- /dev/null +++ b/patch.py @@ -0,0 +1,340 @@ +#!/usr/bin/env python3 +""" +Remnawave Path Rotator +====================== +Меняет path одновременно в: + 1. host.path + host.xHttpExtraParams.path → PATCH /api/hosts + 2. profile.config.inbounds[].xhttpSettings.path → PATCH /api/config-profiles + 3. profile.inbounds[].rawInbound.xhttpSettings.path + +Запуск: + python patch.py --once # однократно + python patch.py # по расписанию (APScheduler) + +Cron: + 0 */6 * * * cd /opt/remna-path-changer && python3 patch.py --once >> /var/log/rw_rotator.log 2>&1 +""" + +import json +import logging +import os +import random +import sys + +import httpx +from apscheduler.schedulers.blocking import BlockingScheduler + +# ───────────────────────────────────────────── +# КОНФИГУРАЦИЯ +# ───────────────────────────────────────────── +PANEL_URL = os.getenv("RW_PANEL_URL", "https://p.avh-vless.work") +API_TOKEN = os.getenv("RW_API_TOKEN", "YOUR TOKEN HERE") +AUTH_COOKIE = os.getenv("RW_AUTH_COOKIE", "YOUR COOKIE HERE") + +# UUID конкретных хостов/профилей через запятую, или пусто = все +TARGET_HOST_UUIDS = [u for u in os.getenv("RW_HOST_UUIDS", "").split(",") if u] +TARGET_PROFILE_UUIDS = [u for u in os.getenv("RW_PROFILE_UUIDS", "").split(",") if u] + +# Расписание APScheduler +CRON_HOUR = os.getenv("RW_CRON_HOUR", "*/6") +CRON_MINUTE = os.getenv("RW_CRON_MINUTE", "0") + +# ───────────────────────────────────────────── +# СЛОВАРЬ +# ───────────────────────────────────────────── +WORD_POOL = [ + "api", "app", "cdn", "data", "edge", "feed", "gate", "hub", "info", + "live", "node", "ping", "proxy", "push", "query", "relay", "rpc", + "secure", "signal", "static", "stream", "sync", "track", + "tunnel", "upload", "update", "web", "ws", "xfer", + "assets", "auth", "bucket", "cache", "chat", "cloud", "connect", + "core", "delta", "direct", "echo", "event", + "fetch", "fiber", "graph", "grpc", + "inbox", "index", "io", "json", "key", + "link", "log", "map", "media", "mesh", "meta", + "mirror", "net", "nexus", "notify", "origin", + "packet", "page", "peer", "pipe", "poll", "raw", + "recv", "ref", "repo", "req", "res", "route", "run", + "session", "srv", "svc", "sys", + "task", "tcp", "tele", "tls", "token", "topic", "trace", + "main", "wiki", "stk", "drive", "audio", "video", + "v1", "v2", "v3", "pub", "open", "close", "new", "old", +] + +PATH_MIN_SEGMENTS = 2 +PATH_MAX_SEGMENTS = 5 + +# ───────────────────────────────────────────── +# ЛОГГЕР +# ───────────────────────────────────────────── +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", +) +log = logging.getLogger("rw_rotator") +logging.getLogger("httpx").setLevel(logging.WARNING) + + +# ───────────────────────────────────────────── +# ГЕНЕРАТОР ПУТИ +# ───────────────────────────────────────────── +def generate_path() -> str: + n = random.randint(PATH_MIN_SEGMENTS, PATH_MAX_SEGMENTS) + segments = random.sample(WORD_POOL, k=n) + return "/" + "/".join(segments) + + +# ───────────────────────────────────────────── +# API CLIENT +# ───────────────────────────────────────────── +class RemnawaveClient: + def __init__(self, base_url: str, token: str, auth_cookie: str = ""): + self.base = base_url.rstrip("/") + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + cookies: dict = {} + for part in auth_cookie.split(";"): + part = part.strip() + if "=" in part: + k, v = part.split("=", 1) + cookies[k.strip()] = v.strip() + self._c = httpx.Client(headers=headers, cookies=cookies, timeout=30) + + def get_all_hosts(self) -> list: + r = self._c.get(f"{self.base}/api/hosts") + r.raise_for_status() + data = r.json() + if isinstance(data, dict): + resp = data.get("response", data) + return resp if isinstance(resp, list) else [] + return data if isinstance(data, list) else [] + + def patch_host(self, uuid: str, payload: dict) -> dict: + """PATCH /api/hosts — обновляет хост, uuid внутри payload""" + payload["uuid"] = uuid + r = self._c.patch(f"{self.base}/api/hosts", json=payload) + r.raise_for_status() + return r.json() + + def get_all_profiles(self) -> list: + r = self._c.get(f"{self.base}/api/config-profiles") + r.raise_for_status() + data = r.json() + if isinstance(data, dict): + resp = data.get("response", {}) + if isinstance(resp, dict): + return resp.get("configProfiles", []) + if isinstance(resp, list): + return resp + return [] + + def get_profile(self, profile_uuid: str) -> dict: + r = self._c.get(f"{self.base}/api/config-profiles/{profile_uuid}") + r.raise_for_status() + data = r.json() + if isinstance(data, dict): + return data.get("response", data) + return data + + def patch_profile(self, uuid: str, payload: dict) -> dict: + """PATCH /api/config-profiles — обновляет профиль целиком""" + payload["uuid"] = uuid + r = self._c.patch(f"{self.base}/api/config-profiles", json=payload) + r.raise_for_status() + return r.json() + + def close(self): + self._c.close() + + +# ───────────────────────────────────────────── +# ПАТЧ ПРОФИЛЯ +# ───────────────────────────────────────────── +def patch_profile_path(profile: dict, new_path: str) -> tuple[dict, bool]: + """ + Меняет path во всех xhttp inbound'ах профиля: + - profile.config.inbounds[].streamSettings.xhttpSettings.path + - profile.inbounds[].rawInbound.streamSettings.xhttpSettings.path + Возвращает (обновлённый профиль, были ли изменения). + """ + changed = False + + # 1. config.inbounds + for inb in profile.get("config", {}).get("inbounds", []): + ss = inb.get("streamSettings", {}) + if ss.get("network") != "xhttp": + continue + xhttp = ss.get("xhttpSettings", {}) + if xhttp.get("path") != new_path: + xhttp["path"] = new_path + ss["xhttpSettings"] = xhttp + changed = True + + # 2. inbounds[].rawInbound + for inb in profile.get("inbounds", []): + raw = inb.get("rawInbound") + if not isinstance(raw, dict): + continue + ss = raw.get("streamSettings", {}) + if ss.get("network") != "xhttp": + continue + xhttp = ss.get("xhttpSettings", {}) + if xhttp.get("path") != new_path: + xhttp["path"] = new_path + ss["xhttpSettings"] = xhttp + changed = True + + return profile, changed + + +# ───────────────────────────────────────────── +# ОСНОВНАЯ ЛОГИКА +# ───────────────────────────────────────────── +def rotate_paths(client: RemnawaveClient): + log.info("═══ Начало ротации путей ═══") + new_path = generate_path() + log.info(f"Новый путь: {new_path}") + + errors = 0 + + # ── 1. ХОСТЫ ─────────────────────────────── + try: + all_hosts = client.get_all_hosts() + log.info(f"Получено хостов: {len(all_hosts)}") + except Exception as e: + log.error(f"Не удалось получить список хостов: {e}") + all_hosts = [] + errors += 1 + + for host in all_hosts: + uuid = host.get("uuid") + remark = host.get("remark", uuid) + if not uuid: + continue + if TARGET_HOST_UUIDS and uuid not in TARGET_HOST_UUIDS: + continue + + # Сохраняем все поля xHttpExtraParams, меняем только path + extra = host.get("xHttpExtraParams") or {} + if isinstance(extra, str): + try: + extra = json.loads(extra) + except Exception: + extra = {} + extra["path"] = new_path + + try: + client.patch_host(uuid=uuid, payload={ + "path": new_path, + "xHttpExtraParams": extra, + }) + log.info(f" ✓ Хост [{remark}] → {new_path}") + except httpx.HTTPStatusError as e: + log.error(f" ✗ Хост [{remark}] — HTTP {e.response.status_code}: {e.response.text[:200]}") + errors += 1 + except Exception as e: + log.error(f" ✗ Хост [{remark}] — {e}") + errors += 1 + + # ── 2. ПРОФИЛИ ────────────────────────────── + try: + all_profiles = client.get_all_profiles() + log.info(f"Получено профилей: {len(all_profiles)}") + except Exception as e: + log.error(f"Не удалось получить профили: {e}") + all_profiles = [] + errors += 1 + + for profile_stub in all_profiles: + if not isinstance(profile_stub, dict): + continue + + p_uuid = profile_stub.get("uuid") + p_name = profile_stub.get("name", p_uuid) + if not p_uuid: + continue + if TARGET_PROFILE_UUIDS and p_uuid not in TARGET_PROFILE_UUIDS: + continue + + # Получаем полный профиль (со всеми inbound'ами и config) + try: + profile = client.get_profile(p_uuid) + except Exception as e: + log.error(f" ✗ Профиль [{p_name}] — не удалось получить: {e}") + errors += 1 + continue + + updated_profile, changed = patch_profile_path(profile, new_path) + if not changed: + log.info(f" · Профиль [{p_name}] — xhttp inbound'ов нет, пропуск") + continue + + # Отправляем обновлённый профиль + # Передаём только config и inbounds (uuid добавит patch_profile) + try: + client.patch_profile(uuid=p_uuid, payload={ + "config": updated_profile.get("config"), + "inbounds": [ + { + "uuid": inb["uuid"], + "rawInbound": inb["rawInbound"], + } + for inb in updated_profile.get("inbounds", []) + if inb.get("uuid") and inb.get("rawInbound") + ], + }) + log.info(f" ✓ Профиль [{p_name}] → {new_path}") + except httpx.HTTPStatusError as e: + log.error(f" ✗ Профиль [{p_name}] — HTTP {e.response.status_code}: {e.response.text[:200]}") + errors += 1 + except Exception as e: + log.error(f" ✗ Профиль [{p_name}] — {e}") + errors += 1 + + status = "с ошибками" if errors else "успешно" + log.info(f"═══ Ротация завершена {status} (ошибок: {errors}) ═══\n") + + +# ───────────────────────────────────────────── +# ТОЧКА ВХОДА +# ───────────────────────────────────────────── +def main(): + if not API_TOKEN: + log.error("RW_API_TOKEN не задан!") + sys.exit(1) + + client = RemnawaveClient(PANEL_URL, API_TOKEN, AUTH_COOKIE) + + if "--once" in sys.argv: + rotate_paths(client) + client.close() + return + + scheduler = BlockingScheduler(timezone="UTC") + scheduler.add_job( + rotate_paths, + trigger="cron", + args=[client], + hour=CRON_HOUR, + minute=CRON_MINUTE, + id="path_rotator", + misfire_grace_time=300, + ) + + log.info(f"Scheduler запущен: {CRON_MINUTE} {CRON_HOUR} * * * UTC") + log.info("Ctrl+C для остановки. --once для разового запуска.") + + try: + rotate_paths(client) + scheduler.start() + except (KeyboardInterrupt, SystemExit): + log.info("Остановка.") + finally: + client.close() + + +if __name__ == "__main__": + main() \ No newline at end of file