Files
remna-path-changer/patch.py
T

340 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()