Загрузить файлы в «/»
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