Загрузить файлы в «/»
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user