#!/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()