From 34654fdc8591db07ac740ca875c1e985df43b4a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Fri, 5 Jun 2026 20:24:37 +0300 Subject: [PATCH] feat: add route preview and relay probe --- README.md | 12 +++-- app.py | 151 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 152 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 36e428c..ebca89f 100644 --- a/README.md +++ b/README.md @@ -14,8 +14,8 @@ - **Аккаунт**: информация о регистрации, применение лицензионного ключа WARP+ - **Установка и удаление** пакета `cloudflare-warp` прямо из браузера (репозиторий Cloudflare для Debian/Ubuntu) - **SOCKS-прокси**: смена порта `warp-cli proxy` (часто `40000` или `1024`) -- **Маршрутизация WARP**: списки доменов/IP, сохранение, включение/выключение правил Amnezia/Xray, тест внешнего IP напрямую и через WARP -- **WARP Relay beta**: UDP relay через `nftables`/`iptables` на выбранный WARP/WireGuard endpoint, single-port или Cloudflare multiport +- **Маршрутизация WARP**: списки доменов/IP, JSON-подсказка для Xray, включение/выключение правил Amnezia/Xray, тест внешнего IP напрямую и через WARP +- **WARP Relay beta**: UDP relay через `nftables`/`iptables` на выбранный WARP/WireGuard endpoint, single-port или Cloudflare multiport, DNS/ping probe endpoint-ов - **Пресет 3x-ui**: outbound `warp-socks` → `127.0.0.1:ПОРТ` и правило маршрутизации `geosite:google` - **Пресет Amnezia**: мост Docker `172.17.0.1:11025` → SOCKS на хосте, маршрутизация WARP для выбранных клиентов с понятными именами @@ -117,6 +117,7 @@ install.sh / uninstall.sh # Установка и снятие «в одн - **Сохранить список** — записывает настройки в `/etc/warp-webui/warp-routes.json`, но не трогает Xray. - **Включить в Amnezia/Xray** — добавляет outbound `warp-socks`, создаёт backup `server.json`, добавляет глобальное routing-правило и перезапускает контейнер Amnezia. - **Выключить правила** — убирает глобальные правила `warp-socks` из Amnezia/Xray, список остаётся сохранённым. +- **JSON для Xray** — показывает готовые куски `outbound` и `routing_rule` для ручной настройки 3x-ui/Amnezia. - **Тест IP** — показывает внешний IP сервера напрямую и внешний IP через `127.0.0.1:WARP_PROXY_PORT`. Важно: обычный Cloudflare WARP не даёт выбрать конкретную страну или конкретный exit IP. Эта функция выбирает, **какие домены/IP пойдут через WARP**, а не страну выхода. Если нужен гарантированный регион, нужен отдельный VPN/proxy с выбранной страной или корпоративный Cloudflare egress. @@ -159,9 +160,10 @@ install.sh / uninstall.sh # Установка и снятие «в одн - выбрать source IP сервера или оставить автоопределение; - включить режим одного UDP-порта, обычно `4500 -> 4500`; - включить Cloudflare multiport mode; +- проверить endpoint через DNS/ping probe и подставить лучший IP в поле endpoint; - удалить только managed relay rules. -Важно: relay не гарантирует конкретный Cloudflare exit IP или страну. Он меняет WARP/WireGuard endpoint, через который клиент подключается. Для "желаемой точки" сейчас используйте ручной endpoint/IP; автоматический список точек и latency probe запланированы. +Важно: relay не гарантирует конкретный Cloudflare exit IP или страну. Он меняет WARP/WireGuard endpoint, через который клиент подключается. Для "желаемой точки" сейчас используйте ручной endpoint/IP или кнопку **Найти endpoint**. ## Безопасность @@ -183,8 +185,8 @@ install.sh / uninstall.sh # Установка и снятие «в одн | POST | `/connect`, `/disconnect`, `/restart` | Управление WARP | | POST | `/warp-install`, `/warp-uninstall` | Установка/удаление пакета | | POST | `/auth-config` | Смена логина/пароля веб-панели | -| POST | `/relay-apply`, `/relay-remove` | Применить/удалить WARP Relay rules | -| POST | `/warp-routes-save`, `/warp-routes-enable`, `/warp-routes-disable`, `/warp-routes-test` | Сохранить/применить/выключить маршруты WARP и проверить внешний IP | +| POST | `/relay-apply`, `/relay-remove`, `/relay-probe` | Применить/удалить WARP Relay rules, проверить endpoint | +| POST | `/warp-routes-save`, `/warp-routes-enable`, `/warp-routes-disable`, `/warp-routes-test`, `/warp-routes-preview` | Сохранить/применить/выключить маршруты WARP, проверить внешний IP, показать JSON для Xray | | POST | `/proxy-port`, `/license` | Порт SOCKS, ключ WARP+ | | POST | `/xui-preset`, `/amnezia-preset`, `/amnezia-routing` | Пресеты интеграций | diff --git a/app.py b/app.py index 8109c51..e1d9e54 100755 --- a/app.py +++ b/app.py @@ -934,6 +934,35 @@ def test_warp_route_ips(): save_warp_routes(state.get("domains"), state.get("ips"), enabled=state.get("enabled"), last_test=result) return 200, result + +def warp_routes_preview(domains=None, ips=None): + domain_items, ip_items = split_domain_ip_routes(domains=domains, ips=ips) + if not domain_items and not ip_items: + domain_items = ["geosite:google"] + proxy_port = get_proxy_port().get("port") or 1024 + rule = {"type": "field", "outboundTag": SOCKS_OUTBOUND_TAG} + if domain_items: + rule["domain"] = domain_items + if ip_items: + rule["ip"] = ip_items + return { + "domains": domain_items, + "ips": ip_items, + "xray_local_outbound": { + "tag": SOCKS_OUTBOUND_TAG, + "protocol": "socks", + "settings": {"servers": [{"address": "127.0.0.1", "port": int(proxy_port)}]}, + }, + "xray_docker_outbound": { + "tag": SOCKS_OUTBOUND_TAG, + "protocol": "socks", + "settings": {"servers": [{"address": BRIDGE_HOST, "port": BRIDGE_PORT}]}, + }, + "routing_rule": rule, + "curl_test": f"curl -4fsS --socks5-hostname 127.0.0.1:{proxy_port} https://api.ipify.org", + "note_ru": "Для 3x-ui используйте xray_local_outbound. Для Amnezia Docker используйте xray_docker_outbound после bridge.", + } + def client_presets(socks_port: int): host_public = os.environ.get("WARP_PUBLIC_HOST", "") bridge = f"{BRIDGE_HOST}:{BRIDGE_PORT}" @@ -993,6 +1022,64 @@ def resolve_relay_endpoint(endpoint: str) -> dict: return {"endpoint": endpoint, "ip": infos[0][4][0], "source": "dns"} +def resolve_relay_endpoint_all(endpoint: str) -> dict: + endpoint = (endpoint or RELAY_DEFAULT_ENDPOINT).strip() + try: + ip = _valid_ipv4(endpoint) + return {"endpoint": endpoint, "ips": [ip], "source": "ip"} + except ValueError: + pass + if not re.fullmatch(r"[A-Za-z0-9.-]{1,253}", endpoint) or ".." in endpoint: + raise ValueError("invalid endpoint") + infos = socket.getaddrinfo(endpoint, None, socket.AF_INET, socket.SOCK_DGRAM) + ips = [] + seen = set() + for info in infos: + ip = info[4][0] + if ip not in seen: + seen.add(ip) + ips.append(ip) + if not ips: + raise ValueError("endpoint has no IPv4 address") + return {"endpoint": endpoint, "ips": ips, "source": "dns"} + + +def _ping_ipv4(ip: str) -> dict: + code, out, err = run_cmd(["ping", "-c", "1", "-W", "1", ip], timeout=3) + text = f"{out}\n{err}" + m = re.search(r"time[=<]([0-9.]+)\s*ms", text) + latency = float(m.group(1)) if m else None + return { + "ip": ip, + "ok": code == 0, + "latency_ms": latency, + "code": code, + "stderr": err, + } + + +def probe_relay_endpoints(endpoint: str) -> tuple[int, dict]: + try: + resolved = resolve_relay_endpoint_all(endpoint or RELAY_DEFAULT_ENDPOINT) + except Exception as e: + return 400, {"error": str(e)} + probes = [_ping_ipv4(ip) for ip in resolved["ips"][:12]] + best = None + ok = [p for p in probes if p.get("ok") and p.get("latency_ms") is not None] + if ok: + best = sorted(ok, key=lambda p: p["latency_ms"])[0] + elif probes: + best = probes[0] + return 200, { + "endpoint": resolved["endpoint"], + "source": resolved["source"], + "count": len(resolved["ips"]), + "probes": probes, + "best": best, + "note_ru": "Это DNS/ping probe endpoint-а, не выбор страны выхода WARP.", + } + + def get_public_ipv4() -> str: for url in ("https://api.ipify.org", "https://icanhazip.com", "https://ifconfig.me"): code, out, _ = run_cmd(["curl", "-4fsS", "--max-time", "3", url], timeout=5) @@ -1417,9 +1504,11 @@ INDEX_HTML = r""" +
+

   
 
   
@@ -1442,10 +1531,12 @@ INDEX_HTML = r"""
+
+

     

   
 
@@ -1517,12 +1608,24 @@ async function api(path, opts={}) {
   const res = await fetch(url, { cache: 'no-store', ...opts });
   const ct = res.headers.get('content-type') || '';
   const body = ct.includes('application/json') ? await res.json() : await res.text();
-  if (!res.ok) throw new Error(typeof body === 'string' ? body : JSON.stringify(body));
+  if (!res.ok) {
+    const err = new Error(typeof body === 'string' ? body : (body.hint || body.detail || body.error || JSON.stringify(body)));
+    err.body = body;
+    err.status = res.status;
+    throw err;
+  }
   return body;
 }
 async function apiPost(path, payload) {
   return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}) });
 }
+function describeError(e) {
+  const body = e && e.body;
+  if (body && typeof body === 'object') {
+    return body.hint || body.detail || body.error || JSON.stringify(body);
+  }
+  return String((e && e.message) || e);
+}
 async function refreshAuthConfig() {
   try {
     const cfg = await api('/auth-config');
@@ -1733,7 +1836,23 @@ el('btnRelayApply').onclick = async () => {
     const st = r.state || {};
     setMsg('relayMsg', 'Готово: ' + st.sourceIp + ':' + st.relayPort + ' -> ' + st.endpointIp + ':' + st.targetPort, true);
     await refreshRelay();
-  } catch (e) { setMsg('relayMsg', String(e.message || e), false); }
+  } catch (e) { setMsg('relayMsg', describeError(e), false); }
+};
+el('btnRelayProbe').onclick = async () => {
+  const endpoint = el('relayEndpoint').value.trim() || 'engage.cloudflareclient.com';
+  setMsg('relayMsg', 'Проверяю endpoint...', null);
+  try {
+    const r = await apiPost('/relay-probe', { endpoint });
+    const best = r.best || {};
+    el('relayProbe').textContent = JSON.stringify(r, null, 2);
+    if (best.ip) {
+      el('relayEndpoint').value = best.ip;
+      const ms = best.latency_ms === null || best.latency_ms === undefined ? 'нет ping' : (best.latency_ms + ' ms');
+      setMsg('relayMsg', 'Лучший endpoint: ' + best.ip + ' (' + ms + '). Поле endpoint заполнено, relay ещё не включён.', true);
+    } else {
+      setMsg('relayMsg', 'Endpoint найден, но ping не ответил. Можно использовать первый IP из JSON.', null);
+    }
+  } catch (e) { setMsg('relayMsg', describeError(e), false); }
 };
 el('btnRelayRemove').onclick = async () => {
   if (!confirm('Удалить только WARP Web UI relay rules?')) return;
@@ -1742,7 +1861,7 @@ el('btnRelayRemove').onclick = async () => {
     await apiPost('/relay-remove', {});
     setMsg('relayMsg', 'Relay rules удалены', true);
     await refreshRelay();
-  } catch (e) { setMsg('relayMsg', String(e.message || e), false); }
+  } catch (e) { setMsg('relayMsg', describeError(e), false); }
 };
 el('btnRoutePresetGoogle').onclick = () => appendUniqueLines('warpRouteDomains', ['geosite:google']);
 el('btnRoutePresetOpenAI').onclick = () => appendUniqueLines('warpRouteDomains', ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com']);
@@ -1764,7 +1883,7 @@ el('btnRouteEnable').onclick = async () => {
     setMsg('warpRouteMsg', 'Маршруты включены; restart rc=' + r.restart_code, r.ok);
     await refreshWarpRoutes();
     await refreshAmneziaClients();
-  } catch (e) { setMsg('warpRouteMsg', String(e.message || e), false); }
+  } catch (e) { setMsg('warpRouteMsg', describeError(e), false); }
 };
 el('btnRouteDisable').onclick = async () => {
   if (!confirm('Выключить глобальные правила WARP в Amnezia/Xray? Список останется сохраненным.')) return;
@@ -1774,7 +1893,15 @@ el('btnRouteDisable').onclick = async () => {
     setMsg('warpRouteMsg', 'Правила выключены; restart rc=' + r.restart_code, r.ok);
     await refreshWarpRoutes();
     await refreshAmneziaClients();
-  } catch (e) { setMsg('warpRouteMsg', String(e.message || e), false); }
+  } catch (e) { setMsg('warpRouteMsg', describeError(e), false); }
+};
+el('btnRoutePreview').onclick = async () => {
+  setMsg('warpRouteMsg', 'Готовлю JSON для Xray...', null);
+  try {
+    const r = await apiPost('/warp-routes-preview', warpRoutesPayload());
+    el('warpRoutePreview').textContent = JSON.stringify(r, null, 2);
+    setMsg('warpRouteMsg', 'JSON готов. local outbound — для 3x-ui; docker outbound — для Amnezia.', true);
+  } catch (e) { setMsg('warpRouteMsg', describeError(e), false); }
 };
 el('btnRouteTest').onclick = async () => {
   setMsg('warpRouteMsg', 'Проверяю внешний IP напрямую и через WARP SOCKS...', null);
@@ -1785,7 +1912,7 @@ el('btnRouteTest').onclick = async () => {
     const suffix = r.same_ip ? 'IP совпадает: WARP может быть не подключен или Cloudflare дал тот же egress.' : 'IP отличается.';
     setMsg('warpRouteMsg', 'Прямой: ' + routeTestLabel(r.direct) + '; через WARP: ' + routeTestLabel(r.warp) + '. ' + suffix, r.warp && r.warp.ok);
     await refreshWarpRoutes();
-  } catch (e) { setMsg('warpRouteMsg', String(e.message || e), false); }
+  } catch (e) { setMsg('warpRouteMsg', describeError(e), false); }
 };
 el('btnXui').onclick = async () => {
   setMsg('xuiMsg', 'Применяю preset x-ui...', null);
@@ -2005,6 +2132,10 @@ class Handler(BaseHTTPRequestHandler):
             code, payload = remove_relay_config()
             return self._json(code, payload)
 
+        if self.path == "/relay-probe":
+            code, payload = probe_relay_endpoints(body.get("endpoint") or RELAY_DEFAULT_ENDPOINT)
+            return self._json(code, payload)
+
         if self.path == "/warp-routes-save":
             domains, ips = split_domain_ip_routes(
                 domains=body.get("domains"),
@@ -2031,6 +2162,14 @@ class Handler(BaseHTTPRequestHandler):
             code, payload = test_warp_route_ips()
             return self._json(code, payload)
 
+        if self.path == "/warp-routes-preview":
+            domains, ips = split_domain_ip_routes(
+                domains=body.get("domains"),
+                ips=body.get("ips"),
+                mixed=body.get("routes"),
+            )
+            return self._json(200, warp_routes_preview(domains, ips))
+
         if self.path == "/xui-preset":
             port = get_proxy_port().get("port") or 1024
             code, payload = apply_xui_preset(int(port))