Загрузить файлы в «/»

This commit is contained in:
2026-05-27 07:47:17 +00:00
parent 08d63d9ca2
commit 0ca90f503b
+340
View File
@@ -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()