340 lines
14 KiB
Python
340 lines
14 KiB
Python
#!/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() |