Files
WARP-Web-UI-Relay-Beta/app.py
Андрей Бобырев 6e905ec600 feat: add WARP route controls
2026-06-05 20:15:46 +03:00

2066 lines
88 KiB
Python
Executable File
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
import base64
import ipaddress
import json
import os
import re
import shlex
import shutil
import socket
import subprocess
import time
from collections import deque
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
import logging
from logging.handlers import RotatingFileHandler
USER = os.environ.get("WARP_WEBUI_USER", "admin")
PASS = os.environ.get("WARP_WEBUI_PASS", "")
if not PASS:
raise SystemExit("WARP_WEBUI_PASS is not set. Configure /etc/default/warp-webui or run install.sh.")
HOST = os.environ.get("WARP_WEBUI_HOST", "0.0.0.0")
PORT = int(os.environ.get("WARP_WEBUI_PORT", "3030"))
ENV_FILE = os.environ.get("WARP_WEBUI_ENV_FILE", "/etc/default/warp-webui")
LOG_DIR = os.environ.get("WARP_WEBUI_LOG_DIR", "/var/log/warp-webui")
LOG_FILE = os.environ.get("WARP_WEBUI_LOG_FILE", os.path.join(LOG_DIR, "warp-webui.log"))
LOG_MAX_BYTES = int(os.environ.get("WARP_WEBUI_LOG_MAX_BYTES", str(2 * 1024 * 1024)))
LOG_BACKUPS = int(os.environ.get("WARP_WEBUI_LOG_BACKUPS", "3"))
BACKUP_DIR = os.environ.get("WARP_WEBUI_BACKUP_DIR", "/var/backups/warp-webui")
INSTALL_SCRIPT = os.environ.get("WARP_INSTALL_SCRIPT", "/opt/warp-webui/scripts/warp-install-cf.sh")
UNINSTALL_SCRIPT = os.environ.get("WARP_UNINSTALL_SCRIPT", "/opt/warp-webui/scripts/warp-uninstall-cf.sh")
XUI_CONFIG = os.environ.get("XUI_CONFIG", "/usr/local/x-ui/bin/config.json")
AMNEZIA_CONTAINER = os.environ.get("AMNEZIA_XRAY_CONTAINER", "amnezia-xray")
AMNEZIA_CONFIG = os.environ.get("AMNEZIA_XRAY_CONFIG", "/opt/amnezia/xray/server.json")
CLIENT_ALIASES_PATH = os.environ.get("WARP_CLIENT_ALIASES", "/etc/warp-webui/client-aliases.json")
BRIDGE_HOST = os.environ.get("WARP_SOCKS_BRIDGE_HOST", "172.17.0.1")
BRIDGE_PORT = int(os.environ.get("WARP_SOCKS_BRIDGE_PORT", "11025"))
SOCKS_OUTBOUND_TAG = "warp-socks"
RELAY_RULE_TAG = os.environ.get("WARP_RELAY_RULE_TAG", "WR_WEBUI_RELAY")
RELAY_STATE_PATH = os.environ.get("WARP_RELAY_STATE", "/etc/warp-webui/relay-state.json")
WARP_ROUTES_PATH = os.environ.get("WARP_ROUTES_PATH", "/etc/warp-webui/warp-routes.json")
RELAY_DEFAULT_ENDPOINT = os.environ.get("WARP_RELAY_DEFAULT_ENDPOINT", "engage.cloudflareclient.com")
NFT_CONF = os.environ.get("WARP_RELAY_NFT_CONF", "/etc/nftables.conf")
RELAY_MULTI_PORTS = [
500, 854, 859, 864, 878, 880, 890, 891, 894, 903, 908, 928, 934, 939, 942,
943, 945, 946, 955, 968, 987, 988, 1002, 1010, 1014, 1018, 1070, 1074,
1180, 1387, 1701, 1843, 2371, 2408, 2506, 3138, 3476, 3581, 3854, 4177,
4198, 4233, 4500, 5279, 5956, 7103, 7152, 7156, 7281, 7559, 8319, 8742,
8854, 8886,
]
LOG_BUFFER = deque(maxlen=250)
def _utc_now_iso():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def setup_logging():
os.makedirs(LOG_DIR, exist_ok=True)
logger = logging.getLogger("warp-webui")
logger.setLevel(logging.INFO)
if not any(isinstance(h, RotatingFileHandler) for h in logger.handlers):
fh = RotatingFileHandler(LOG_FILE, maxBytes=LOG_MAX_BYTES, backupCount=LOG_BACKUPS)
fh.setLevel(logging.INFO)
fh.setFormatter(logging.Formatter("%(asctime)sZ %(levelname)s %(message)s"))
logger.addHandler(fh)
return logger
LOGGER = setup_logging()
def log_event(level: str, message: str, **fields):
entry = {"ts": _utc_now_iso(), "level": level, "message": message, **fields}
LOG_BUFFER.append(entry)
try:
LOGGER.info(json.dumps(entry, ensure_ascii=True))
except Exception:
pass
def run_cmd(cmd, timeout=120):
start = time.time()
try:
proc = subprocess.run(cmd, shell=False, capture_output=True, text=True, timeout=timeout)
except FileNotFoundError as e:
dur_ms = int((time.time() - start) * 1000)
msg = f"command not found: {e.filename or cmd[0]}"
log_event("error", "command_not_found", cmd=cmd, duration_ms=dur_ms, stderr=msg)
return 127, "", msg
except subprocess.TimeoutExpired as e:
dur_ms = int((time.time() - start) * 1000)
log_event("error", "command_timeout", cmd=cmd, duration_ms=dur_ms)
return 124, "", "timeout"
dur_ms = int((time.time() - start) * 1000)
out = (proc.stdout or "").strip()
err = (proc.stderr or "").strip()
log_event(
"info",
"command_executed",
cmd=cmd,
returncode=proc.returncode,
duration_ms=dur_ms,
stdout_tail=out[-2000:],
stderr_tail=err[-2000:],
)
return proc.returncode, out, err
def backup_file(path: str, label: str):
os.makedirs(BACKUP_DIR, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
base = os.path.basename(path)
dest = os.path.join(BACKUP_DIR, f"{label}-{base}.{ts}")
shutil.copy2(path, dest)
return dest
def auth_config():
return {"user": USER, "env_file": ENV_FILE}
def _env_quote(value: str) -> str:
return shlex.quote(value)
def _write_env_values(path: str, updates: dict):
lines = []
seen = set()
if os.path.isfile(path):
with open(path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
new_lines = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in line:
new_lines.append(line)
continue
key = line.split("=", 1)[0].strip()
if key in updates:
new_lines.append(f"{key}={_env_quote(updates[key])}")
seen.add(key)
else:
new_lines.append(line)
for key, value in updates.items():
if key not in seen:
new_lines.append(f"{key}={_env_quote(value)}")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines))
f.write("\n")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
def update_webui_credentials(user: str, password):
user = (user or "").strip()
if not re.fullmatch(r"[A-Za-z0-9_.@-]{3,64}", user):
return 400, {"error": "Логин: 3-64 символа, латиница/цифры/._@-"}
updates = {"WARP_WEBUI_USER": user}
if password is not None and password != "":
if len(password) < 8 or len(password) > 128:
return 400, {"error": "Пароль должен быть от 8 до 128 символов"}
if "\n" in password or "\r" in password or "\x00" in password:
return 400, {"error": "Пароль не должен содержать перенос строки"}
updates["WARP_WEBUI_PASS"] = password
backup = backup_file(ENV_FILE, "env") if os.path.isfile(ENV_FILE) else None
_write_env_values(ENV_FILE, updates)
log_event("info", "auth_config_updated", user=user, backup=backup)
if shutil.which("systemctl"):
subprocess.Popen(
["/bin/sh", "-c", "sleep 2; systemctl restart warp-webui.service"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
start_new_session=True,
)
return 200, {
"ok": True,
"user": user,
"backup": backup,
"env_file": ENV_FILE,
"restart": "scheduled",
"note": "Сервис перезапустится. Войдите заново с новым логином/паролем.",
}
def parse_warp_status_text(stdout: str, stderr: str, code: int):
connected = None
health = None
account = None
device = None
text = stdout or ""
low = text.lower()
if "connected" in low and "disconnected" not in low:
connected = True
if "disconnected" in low:
connected = False
for line in text.splitlines():
l = line.strip()
ll = l.lower()
if ll.startswith("status") and ":" in l:
val = l.split(":", 1)[1].strip().lower()
if "connected" in val:
connected = True
if "disconnected" in val:
connected = False
if ll.startswith("health") and ":" in l:
health = l.split(":", 1)[1].strip()
if ll.startswith("account") and ":" in l:
account = l.split(":", 1)[1].strip()
if ll.startswith("device") and ":" in l:
device = l.split(":", 1)[1].strip()
return {
"connected": connected,
"health": health,
"account": account,
"device": device,
"code": code,
"stdout": stdout,
"stderr": stderr,
}
def warp_status():
code, out, err = run_cmd(["warp-cli", "--accept-tos", "status"])
return parse_warp_status_text(out, err, code)
def parse_registration(stdout: str):
raw_lines = []
info = {"raw": "", "account_type": None, "account_id": None, "device_id": None, "license_masked": None}
for line in (stdout or "").splitlines():
l = line.strip()
if l.lower().startswith("account type:"):
info["account_type"] = l.split(":", 1)[1].strip()
elif l.lower().startswith("account id:"):
info["account_id"] = l.split(":", 1)[1].strip()
elif l.lower().startswith("device id:"):
info["device_id"] = l.split(":", 1)[1].strip()
elif l.lower().startswith("license:"):
lic = l.split(":", 1)[1].strip()
if lic:
info["license_masked"] = lic[:4] + "" + lic[-4:] if len(lic) > 10 else lic
raw_lines.append("License: " + info["license_masked"])
continue
raw_lines.append(line)
info["raw"] = "\n".join(raw_lines)
return info
def warp_registration():
code, out, err = run_cmd(["warp-cli", "--accept-tos", "registration", "show"])
data = parse_registration(out)
data.update({"code": code, "stderr": err})
return data
def get_proxy_port():
code, out, err = run_cmd(["warp-cli", "--accept-tos", "settings"])
m = re.search(r"WarpProxy on port (\d+)", out or "")
if m:
return {"port": int(m.group(1)), "source": "settings", "code": code}
code2, out2, err2 = run_cmd(["ss", "-lnt"])
for line in (out2 or "").splitlines():
if "127.0.0.1:" in line:
mm = re.search(r"127\.0\.0\.1:(\d+).*warp-svc", line)
if mm:
return {"port": int(mm.group(1)), "source": "ss", "code": code2}
return {"port": None, "source": "unknown", "code": code, "stderr": err}
def set_proxy_port(port: int):
if port < 1 or port > 65535:
return 400, {"error": "invalid port"}
c1, o1, e1 = run_cmd(["warp-cli", "--accept-tos", "mode", "proxy"])
c2, o2, e2 = run_cmd(["warp-cli", "--accept-tos", "proxy", "port", str(port)])
ok = c1 == 0 and c2 == 0
return (200 if ok else 500), {
"port": port,
"mode_code": c1,
"port_code": c2,
"stdout": "\n".join(filter(None, [o1, o2])),
"stderr": "\n".join(filter(None, [e1, e2])),
"proxy": get_proxy_port(),
}
def apply_license_key(key: str):
key = (key or "").strip()
if not re.fullmatch(r"[A-Za-z0-9-]{8,64}", key):
return 400, {"error": "invalid license key format"}
code, out, err = run_cmd(["warp-cli", "--accept-tos", "registration", "license", key])
return (200 if code == 0 else 500), {
"result_code": code,
"stdout": out,
"stderr": err,
"registration": warp_registration(),
}
def run_script(path: str, extra_env=None):
if not os.path.isfile(path) or not os.access(path, os.X_OK):
return 500, {"error": "script missing", "path": path}
env = os.environ.copy()
if extra_env:
env.update(extra_env)
cmd = [path]
runner = "direct"
if shutil.which("systemd-run") and os.geteuid() == 0:
unit = "warp-webui-script-" + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
cmd = [
"systemd-run",
"--wait",
"--pipe",
"--collect",
"--quiet",
f"--unit={unit}",
]
for key, value in (extra_env or {}).items():
cmd.append(f"--setenv={key}={value}")
cmd.append(path)
runner = "systemd-run"
start = time.time()
proc = subprocess.run(cmd, shell=False, capture_output=True, text=True, timeout=900, env=env)
dur_ms = int((time.time() - start) * 1000)
log_event(
"info",
"script_executed",
path=path,
runner=runner,
returncode=proc.returncode,
duration_ms=dur_ms,
stdout_tail=(proc.stdout or "")[-2000:],
stderr_tail=(proc.stderr or "")[-2000:],
)
return (
200 if proc.returncode == 0 else 500,
{
"path": path,
"runner": runner,
"result_code": proc.returncode,
"stdout": (proc.stdout or "").strip(),
"stderr": (proc.stderr or "").strip(),
"duration_ms": dur_ms,
},
)
def ensure_socks_bridge(target_port: int):
unit_path = "/etc/systemd/system/warp-socks-bridge.service"
unit = f"""[Unit]
Description=WARP SOCKS bridge for Docker ({BRIDGE_HOST}:{BRIDGE_PORT})
After=network-online.target warp-svc.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/socat TCP-LISTEN:{BRIDGE_PORT},bind={BRIDGE_HOST},reuseaddr,fork TCP:127.0.0.1:{target_port}
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
"""
with open(unit_path, "w", encoding="utf-8") as f:
f.write(unit)
run_cmd(["systemctl", "daemon-reload"])
c1, _, _ = run_cmd(["systemctl", "enable", "--now", "warp-socks-bridge.service"])
c2, out, err = run_cmd(["systemctl", "is-active", "warp-socks-bridge.service"])
return {
"unit": unit_path,
"bridge": f"{BRIDGE_HOST}:{BRIDGE_PORT}",
"target": f"127.0.0.1:{target_port}",
"enable_code": c1,
"active": out.strip() if c2 == 0 else "unknown",
"stderr": err,
}
def _merge_xui_config(cfg: dict, socks_port: int):
outbounds = cfg.setdefault("outbounds", [])
socks = {
"tag": SOCKS_OUTBOUND_TAG,
"protocol": "socks",
"settings": {
"servers": [
{
"address": "127.0.0.1",
"port": socks_port,
"users": [],
}
]
},
}
replaced = False
for i, ob in enumerate(outbounds):
if ob.get("tag") == SOCKS_OUTBOUND_TAG:
outbounds[i] = socks
replaced = True
break
if not replaced:
outbounds.append(socks)
routing = cfg.setdefault("routing", {})
rules = routing.setdefault("rules", [])
rule = {
"type": "field",
"domain": ["geosite:google"],
"outboundTag": SOCKS_OUTBOUND_TAG,
}
if not any(r.get("outboundTag") == SOCKS_OUTBOUND_TAG and "geosite:google" in str(r.get("domain")) for r in rules):
rules.append(rule)
return cfg
def apply_xui_preset(socks_port: int):
if not os.path.isfile(XUI_CONFIG):
return 500, {"error": "x-ui config not found", "path": XUI_CONFIG}
backup = backup_file(XUI_CONFIG, "x-ui")
with open(XUI_CONFIG, "r", encoding="utf-8") as f:
cfg = json.load(f)
cfg = _merge_xui_config(cfg, socks_port)
with open(XUI_CONFIG, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
rc, out, err = run_cmd(["systemctl", "restart", "x-ui"])
return (200 if rc == 0 else 500), {
"backup": backup,
"config": XUI_CONFIG,
"socks": f"127.0.0.1:{socks_port}",
"restart_code": rc,
"stderr": err,
}
def _read_amnezia_config():
code, out, err = run_cmd(["docker", "exec", AMNEZIA_CONTAINER, "cat", AMNEZIA_CONFIG])
if code != 0:
return None, err or out
return json.loads(out), ""
def _write_amnezia_config(cfg: dict):
tmp = os.path.join(BACKUP_DIR, "amnezia-server.json.tmp")
os.makedirs(BACKUP_DIR, exist_ok=True)
with open(tmp, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
dest = f"{AMNEZIA_CONTAINER}:{AMNEZIA_CONFIG}"
rc, out, err = run_cmd(["docker", "cp", tmp, dest])
if rc != 0:
return rc, err or out
rc2, out2, err2 = run_cmd(["docker", "restart", AMNEZIA_CONTAINER])
return rc2, err2 or out2
def _merge_amnezia_config(cfg: dict, bridge_port: int):
outbounds = cfg.setdefault("outbounds", [])
socks = {
"tag": SOCKS_OUTBOUND_TAG,
"protocol": "socks",
"settings": {
"servers": [
{
"address": BRIDGE_HOST,
"port": bridge_port,
}
]
},
}
replaced = False
for i, ob in enumerate(outbounds):
if ob.get("tag") == SOCKS_OUTBOUND_TAG:
outbounds[i] = socks
replaced = True
break
if not replaced:
outbounds.insert(0, socks)
routing = cfg.setdefault("routing", {})
rules = routing.setdefault("rules", [])
rule = {
"type": "field",
"domain": ["geosite:google"],
"outboundTag": SOCKS_OUTBOUND_TAG,
}
if not any(r.get("outboundTag") == SOCKS_OUTBOUND_TAG and "geosite:google" in str(r.get("domain")) for r in rules):
rules.append(rule)
return cfg
def apply_amnezia_preset(socks_port: int):
bridge_info = ensure_socks_bridge(socks_port)
cfg, err = _read_amnezia_config()
if cfg is None:
return 500, {"error": "read amnezia config failed", "detail": err, "bridge": bridge_info}
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
snap = os.path.join(BACKUP_DIR, f"amnezia-server.json.{ts}")
with open(snap, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
cfg = _merge_amnezia_config(cfg, BRIDGE_PORT)
rc, detail = _write_amnezia_config(cfg)
client_rows = list_amnezia_clients_from_cfg(cfg)
log_event(
"info",
"amnezia_preset_applied",
clients=format_clients_for_log(client_rows),
)
return (200 if rc == 0 else 500), {
"backup": snap,
"bridge": bridge_info,
"socks_via": f"{BRIDGE_HOST}:{BRIDGE_PORT} -> 127.0.0.1:{socks_port}",
"restart_code": rc,
"detail": detail,
"clients": client_rows,
"clients_display": format_clients_for_log(client_rows),
"ok": rc == 0,
}
_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.I,
)
def _short_uuid(uid: str) -> str:
uid = (uid or "").strip()
if len(uid) > 20:
return uid[:8] + "" + uid[-8:]
return uid
def _is_uuid_like(s: str) -> bool:
return bool(_UUID_RE.fullmatch((s or "").strip()))
def load_client_aliases() -> dict:
try:
if os.path.isfile(CLIENT_ALIASES_PATH):
with open(CLIENT_ALIASES_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return {str(k).lower(): str(v).strip() for k, v in data.items() if str(v).strip()}
except Exception as e:
log_event("warning", "aliases_load_failed", path=CLIENT_ALIASES_PATH, error=str(e))
return {}
def save_client_aliases(aliases: dict) -> dict:
os.makedirs(os.path.dirname(CLIENT_ALIASES_PATH) or "/etc/warp-webui", exist_ok=True)
normalized = {}
for k, v in (aliases or {}).items():
key = str(k).strip().lower()
val = str(v).strip()
if key and val:
normalized[key] = val
tmp = CLIENT_ALIASES_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(normalized, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp, CLIENT_ALIASES_PATH)
return normalized
def resolve_client_display_name(client_id, email=None, comment=None, aliases=None):
cid = (client_id or "").strip()
cid_lower = cid.lower()
aliases = aliases if aliases is not None else load_client_aliases()
if cid_lower in aliases:
return aliases[cid_lower], "alias"
comment = (comment or "").strip()
if comment and not _is_uuid_like(comment):
return comment, "comment"
em = (email or "").strip()
if em and not _is_uuid_like(em) and em.lower() != cid_lower:
return em, "email"
return cid, "uuid"
def enrich_amnezia_client_row(c: dict, aliases=None):
cid = (c.get("id") or "").strip()
email = (c.get("email") or cid).strip()
comment = (c.get("comment") or "").strip()
display, source = resolve_client_display_name(cid, email=email, comment=comment, aliases=aliases)
routing_user = email or cid
return {
"uuid": cid,
"id": cid,
"email": email,
"flow": c.get("flow", ""),
"comment": comment,
"displayName": display,
"shortUuid": _short_uuid(cid),
"source": source,
"routingUser": routing_user,
}
def format_clients_for_log(clients):
labels = []
for c in clients or []:
if isinstance(c, dict):
name = c.get("displayName") or c.get("email") or c.get("uuid") or c.get("id")
uid = c.get("uuid") or c.get("id") or ""
labels.append(f"{name} ({_short_uuid(uid)})")
else:
labels.append(str(c))
return labels
def users_to_display_summary(users, cfg=None):
if cfg is None:
cfg, _ = _read_amnezia_config()
by_routing = {}
if cfg:
for row in list_amnezia_clients_from_cfg(cfg):
by_routing[row["routingUser"]] = row
names = []
for u in users or []:
row = by_routing.get(u)
if row:
names.append(f"{row['displayName']} ({row['shortUuid']})")
elif _is_uuid_like(u):
names.append(_short_uuid(u))
else:
names.append(u)
return names
def list_amnezia_clients_from_cfg(cfg: dict):
aliases = load_client_aliases()
clients = []
for inbound in cfg.get("inbounds", []):
if inbound.get("protocol") != "vless":
continue
for c in inbound.get("settings", {}).get("clients", []):
clients.append(enrich_amnezia_client_row(c, aliases=aliases))
return clients
def list_amnezia_clients():
cfg, err = _read_amnezia_config()
if cfg is None:
return None, err
return list_amnezia_clients_from_cfg(cfg), ""
def parse_route_list(value):
if isinstance(value, list):
chunks = value
else:
chunks = re.split(r"[\n,;]+", str(value or ""))
seen = set()
items = []
for raw in chunks:
item = str(raw or "").strip()
if not item:
continue
key = item.lower()
if key in seen:
continue
seen.add(key)
items.append(item)
return items
def split_domain_ip_routes(domains=None, ips=None, mixed=None):
domain_items = parse_route_list(domains)
ip_items = parse_route_list(ips)
for item in parse_route_list(mixed):
low = item.lower()
if low.startswith(("geosite:", "domain:", "full:", "regexp:", "keyword:")):
domain_items.append(item)
continue
if low.startswith(("geoip:", "ext:geoip")):
ip_items.append(item)
continue
try:
ipaddress.ip_network(item, strict=False)
ip_items.append(item)
except ValueError:
domain_items.append(item)
def uniq(items):
out = []
seen = set()
for item in items:
key = item.lower()
if key not in seen:
seen.add(key)
out.append(item)
return out
return uniq(domain_items), uniq(ip_items)
def load_warp_routes() -> dict:
default = {
"enabled": False,
"domains": ["geosite:google"],
"ips": [],
"last_test": {},
"updated_at": None,
}
try:
if os.path.isfile(WARP_ROUTES_PATH):
with open(WARP_ROUTES_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
default.update({
"enabled": bool(data.get("enabled")),
"domains": parse_route_list(data.get("domains")) or ["geosite:google"],
"ips": parse_route_list(data.get("ips")),
"last_test": data.get("last_test") if isinstance(data.get("last_test"), dict) else {},
"updated_at": data.get("updated_at"),
})
except Exception as e:
log_event("warning", "warp_routes_load_failed", path=WARP_ROUTES_PATH, error=str(e))
default["path"] = WARP_ROUTES_PATH
return default
def save_warp_routes(domains=None, ips=None, enabled=None, last_test=None) -> dict:
current = load_warp_routes()
domain_items, ip_items = split_domain_ip_routes(domains=domains, ips=ips)
state = {
"enabled": current["enabled"] if enabled is None else bool(enabled),
"domains": domain_items or ["geosite:google"],
"ips": ip_items,
"last_test": current.get("last_test", {}),
"updated_at": _utc_now_iso(),
}
if last_test is not None:
state["last_test"] = last_test
os.makedirs(os.path.dirname(WARP_ROUTES_PATH) or "/etc/warp-webui", exist_ok=True)
tmp = WARP_ROUTES_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp, WARP_ROUTES_PATH)
state["path"] = WARP_ROUTES_PATH
return state
def _strip_managed_warp_rules(rules, strip_global=False):
kept = []
for r in rules:
if r.get("outboundTag") != SOCKS_OUTBOUND_TAG:
kept.append(r)
continue
if strip_global and not r.get("user"):
continue
if r.get("user"):
continue
if "geosite:google" in str(r.get("domain", "")):
continue
kept.append(r)
return kept
def _append_warp_routing_rules(cfg: dict, users, domains=None, ips=None, strip_global=False):
routing = cfg.setdefault("routing", {})
rules = routing.setdefault("rules", [])
rules = _strip_managed_warp_rules(rules, strip_global=strip_global)
domains, ips = split_domain_ip_routes(domains=domains, ips=ips)
if not domains and not ips:
domains = ["geosite:google"]
users = parse_route_list(users)
def build_rule(user=None):
rule = {"type": "field", "outboundTag": SOCKS_OUTBOUND_TAG}
if user:
rule["user"] = [user]
if domains:
rule["domain"] = domains
if ips:
rule["ip"] = ips
return rule
if users:
for u in users:
rules.append(build_rule(u))
else:
rules.append(build_rule())
routing["rules"] = rules
return cfg
def apply_amnezia_routing(socks_port: int, users=None, domains=None):
bridge_info = ensure_socks_bridge(socks_port)
cfg, err = _read_amnezia_config()
if cfg is None:
return 500, {"error": "read amnezia config failed", "detail": err, "bridge": bridge_info}
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
snap = os.path.join(BACKUP_DIR, f"amnezia-server.json.{ts}")
os.makedirs(BACKUP_DIR, exist_ok=True)
with open(snap, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
cfg = _merge_amnezia_config(cfg, BRIDGE_PORT)
cfg = _append_warp_routing_rules(cfg, users, domains)
rc, detail = _write_amnezia_config(cfg)
client_rows = list_amnezia_clients_from_cfg(cfg)
users_display = users_to_display_summary(users, cfg=cfg)
log_event(
"info",
"amnezia_routing_applied",
users=users or [],
users_display=users_display,
domains=domains or ["geosite:google"],
)
return (200 if rc == 0 else 500), {
"backup": snap,
"bridge": bridge_info,
"users": users or [],
"users_display": users_display,
"domains": domains or ["geosite:google"],
"clients": client_rows,
"routing_rules": cfg.get("routing", {}).get("rules", []),
"restart_code": rc,
"detail": detail,
"ok": rc == 0,
}
def apply_warp_routes_to_amnezia(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"]
bridge_info = ensure_socks_bridge(get_proxy_port().get("port") or 1024)
cfg, err = _read_amnezia_config()
if cfg is None:
state = save_warp_routes(domain_items, ip_items, enabled=False)
return 500, {
"error": "amnezia config read failed",
"detail": err,
"hint": f"Контейнер {AMNEZIA_CONTAINER} или файл {AMNEZIA_CONFIG} не найден. Список сохранен, но правила в Xray не применены.",
"bridge": bridge_info,
"state": state,
}
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
snap = os.path.join(BACKUP_DIR, f"amnezia-server.json.{ts}")
os.makedirs(BACKUP_DIR, exist_ok=True)
with open(snap, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
cfg = _merge_amnezia_config(cfg, BRIDGE_PORT)
cfg = _append_warp_routing_rules(cfg, users=None, domains=domain_items, ips=ip_items, strip_global=True)
rc, detail = _write_amnezia_config(cfg)
state = save_warp_routes(domain_items, ip_items, enabled=(rc == 0))
log_event("info" if rc == 0 else "error", "warp_routes_apply", domains=domain_items, ips=ip_items, restart_code=rc)
return (200 if rc == 0 else 500), {
"ok": rc == 0,
"backup": snap,
"bridge": bridge_info,
"domains": domain_items,
"ips": ip_items,
"routing_rules": cfg.get("routing", {}).get("rules", []),
"restart_code": rc,
"detail": detail,
"state": state,
}
def disable_warp_routes_in_amnezia():
cfg, err = _read_amnezia_config()
if cfg is None:
state = load_warp_routes()
state = save_warp_routes(state.get("domains"), state.get("ips"), enabled=False)
return 500, {
"error": "amnezia config read failed",
"detail": err,
"hint": f"Контейнер {AMNEZIA_CONTAINER} или файл {AMNEZIA_CONFIG} не найден. Флаг включения снят, но Xray-конфиг не менялся.",
"state": state,
}
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
snap = os.path.join(BACKUP_DIR, f"amnezia-server.json.{ts}")
os.makedirs(BACKUP_DIR, exist_ok=True)
with open(snap, "w", encoding="utf-8") as f:
json.dump(cfg, f, ensure_ascii=False, indent=2)
routing = cfg.setdefault("routing", {})
routing["rules"] = _strip_managed_warp_rules(routing.get("rules", []), strip_global=True)
rc, detail = _write_amnezia_config(cfg)
state = load_warp_routes()
state = save_warp_routes(state.get("domains"), state.get("ips"), enabled=False)
log_event("info" if rc == 0 else "error", "warp_routes_disable", restart_code=rc)
return (200 if rc == 0 else 500), {
"ok": rc == 0,
"backup": snap,
"routing_rules": routing.get("rules", []),
"restart_code": rc,
"detail": detail,
"state": state,
}
def _curl_external_ip(args, timeout=15):
urls = ["https://api.ipify.org", "https://icanhazip.com"]
attempts = []
for url in urls:
code, out, err = run_cmd(["curl", "-4fsS", "--max-time", str(timeout), *args, url], timeout=timeout + 3)
clean = (out or "").strip()
attempts.append({"url": url, "code": code, "stdout": clean, "stderr": err})
if code == 0 and clean:
return {"ok": True, "ip": clean.splitlines()[0].strip(), "url": url, "attempts": attempts}
return {"ok": False, "ip": None, "attempts": attempts, "error": attempts[-1]["stderr"] if attempts else "curl failed"}
def test_warp_route_ips():
proxy_port = get_proxy_port().get("port") or 1024
direct = _curl_external_ip([])
warp = _curl_external_ip(["--socks5-hostname", f"127.0.0.1:{proxy_port}"], timeout=18)
result = {
"direct": direct,
"warp": warp,
"proxy": f"127.0.0.1:{proxy_port}",
"same_ip": bool(direct.get("ip") and direct.get("ip") == warp.get("ip")),
"tested_at": _utc_now_iso(),
}
state = load_warp_routes()
save_warp_routes(state.get("domains"), state.get("ips"), enabled=state.get("enabled"), last_test=result)
return 200, result
def client_presets(socks_port: int):
host_public = os.environ.get("WARP_PUBLIC_HOST", "")
bridge = f"{BRIDGE_HOST}:{BRIDGE_PORT}"
local = f"127.0.0.1:{socks_port}"
return {
"socks_port": socks_port,
"local_socks": local,
"docker_bridge_socks": bridge,
"xray_outbound": {
"tag": SOCKS_OUTBOUND_TAG,
"protocol": "socks",
"settings": {
"servers": [{"address": "127.0.0.1", "port": socks_port}]
},
},
"amnezia_outbound": {
"tag": SOCKS_OUTBOUND_TAG,
"protocol": "socks",
"settings": {
"servers": [{"address": BRIDGE_HOST, "port": BRIDGE_PORT}]
},
},
"curl_example": f"curl -s --socks5-hostname {local} https://api.ipify.org",
"v2rayN_socks": {"protocol": "socks", "server": "127.0.0.1", "port": socks_port},
"note": f"On server use {local}; from Amnezia container use {bridge} (socat bridge).",
"public_host_hint": host_public,
}
def _valid_port(value) -> int:
port = int(value)
if port < 1 or port > 65535:
raise ValueError("invalid port")
return port
def _valid_ipv4(value: str) -> str:
ip = ipaddress.ip_address((value or "").strip())
if ip.version != 4:
raise ValueError("IPv4 required")
return str(ip)
def resolve_relay_endpoint(endpoint: str) -> dict:
endpoint = (endpoint or RELAY_DEFAULT_ENDPOINT).strip()
if not endpoint:
raise ValueError("endpoint required")
try:
return {"endpoint": endpoint, "ip": _valid_ipv4(endpoint), "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)
if not infos:
raise ValueError("endpoint has no IPv4 address")
return {"endpoint": endpoint, "ip": infos[0][4][0], "source": "dns"}
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)
if code == 0:
try:
return _valid_ipv4((out or "").strip().splitlines()[0])
except Exception:
continue
raise ValueError("Не удалось определить публичный IPv4; укажите sourceIp вручную")
def detect_relay_firewall():
if shutil.which("nft"):
code, _, err = run_cmd(["nft", "list", "ruleset"], timeout=5)
if code == 0 and "managed by iptables-nft" in (err or "") and shutil.which("iptables"):
return "iptables"
return "nftables"
if shutil.which("iptables"):
return "iptables"
return None
def load_relay_state() -> dict:
try:
if os.path.isfile(RELAY_STATE_PATH):
with open(RELAY_STATE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
return data
except Exception as e:
log_event("warning", "relay_state_load_failed", path=RELAY_STATE_PATH, error=str(e))
return {
"enabled": False,
"mode": "single",
"endpoint": RELAY_DEFAULT_ENDPOINT,
"sourceIp": "",
"relayPort": 4500,
"targetPort": 4500,
}
def save_relay_state(state: dict) -> dict:
os.makedirs(os.path.dirname(RELAY_STATE_PATH) or "/etc/warp-webui", exist_ok=True)
state = dict(state or {})
state["updatedAt"] = _utc_now_iso()
tmp = RELAY_STATE_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(tmp, RELAY_STATE_PATH)
return state
def enable_relay_forwarding():
with open("/etc/sysctl.d/ipv4-forwarding.conf", "w", encoding="utf-8") as f:
f.write("net.ipv4.ip_forward=1\n")
return run_cmd(["sysctl", "-w", "net.ipv4.ip_forward=1"])
def _relay_nft(args, timeout=30):
return run_cmd(["nft", *args], timeout=timeout)
def _relay_clean_nft_rules():
code, out, err = _relay_nft(["-a", "list", "ruleset"])
deleted = []
if code == 0:
family = table = chain = None
for line in (out or "").splitlines():
stripped = line.strip()
mt = re.match(r"table\s+(\S+)\s+(\S+)\s+\{", stripped)
if mt:
family, table, chain = mt.group(1), mt.group(2), None
continue
mc = re.match(r"chain\s+(\S+)\s+\{", stripped)
if mc:
chain = mc.group(1)
continue
mh = re.search(r"# handle (\d+)", stripped)
if f'comment "{RELAY_RULE_TAG}"' in stripped and mh and family and table and chain:
rc, _, detail = _relay_nft(["delete", "rule", family, table, chain, "handle", mh.group(1)])
deleted.append({"table": f"{family} {table}", "chain": chain, "handle": mh.group(1), "code": rc, "detail": detail})
for table in ("nat", "filter"):
_relay_nft(["delete", "set", "ip", table, "wr_webui_relay_ports"])
return {"deleted": deleted, "list_code": code, "list_error": err}
def _relay_clean_iptables_rules():
deleted = []
for table_args in (["-t", "nat"], []):
code, out, err = run_cmd(["iptables", *table_args, "-S"])
if code != 0:
deleted.append({"table": table_args or ["filter"], "code": code, "detail": err})
continue
for line in (out or "").splitlines():
if RELAY_RULE_TAG not in line or not line.startswith("-A "):
continue
delete_args = shlex.split(line.replace("-A", "-D", 1))
rc, _, detail = run_cmd(["iptables", *table_args, *delete_args])
deleted.append({"table": table_args or ["filter"], "rule": line, "code": rc, "detail": detail})
return {"deleted": deleted}
def clean_relay_rules(firewall=None) -> dict:
firewall = firewall or detect_relay_firewall()
if firewall == "nftables":
return _relay_clean_nft_rules()
if firewall == "iptables":
return _relay_clean_iptables_rules()
return {"error": "nftables or iptables not found"}
def _save_relay_firewall(firewall: str):
if firewall == "nftables":
code, out, err = _relay_nft(["list", "ruleset"])
if code == 0:
with open(NFT_CONF, "w", encoding="utf-8") as f:
f.write(out)
f.write("\n")
if shutil.which("systemctl"):
run_cmd(["systemctl", "enable", "nftables"])
run_cmd(["systemctl", "restart", "nftables"])
return {"code": code, "path": NFT_CONF, "stderr": err}
if shutil.which("netfilter-persistent"):
code, out, err = run_cmd(["netfilter-persistent", "save"])
return {"code": code, "stdout": out, "stderr": err}
return {"code": 0, "note": "iptables rules active until reboot; install netfilter-persistent to persist"}
def _apply_relay_nft(src_ip: str, dst_ip: str, relay_port: int, target_port: int, mode: str):
cmds = [
["add", "table", "ip", "nat"],
["add", "table", "ip", "filter"],
["add", "chain", "ip", "nat", "prerouting", "{", "type", "nat", "hook", "prerouting", "priority", "-100", ";", "}"],
["add", "chain", "ip", "nat", "postrouting", "{", "type", "nat", "hook", "postrouting", "priority", "100", ";", "}"],
["add", "chain", "ip", "filter", "forward", "{", "type", "filter", "hook", "forward", "priority", "filter", ";", "}"],
]
results = []
critical = []
for args in cmds:
rc, out, err = _relay_nft(args)
results.append({"cmd": ["nft", *args], "code": rc, "stderr": err, "stdout": out})
if mode == "multiport":
for table in ("nat", "filter"):
res = _nft_result(["add", "set", "ip", table, "wr_webui_relay_ports", "{", "type", "inet_service", ";", "flags", "interval", ";", "}"])
results.append(res)
critical.append(res)
for port in RELAY_MULTI_PORTS:
res = _nft_result(["add", "element", "ip", table, "wr_webui_relay_ports", "{", str(port), "}"])
results.append(res)
critical.append(res)
rule_cmds = [
["add", "rule", "ip", "nat", "prerouting", "ip", "daddr", src_ip, "udp", "dport", "@wr_webui_relay_ports", "dnat", "to", dst_ip, "comment", RELAY_RULE_TAG],
["add", "rule", "ip", "nat", "postrouting", "ip", "daddr", dst_ip, "udp", "dport", "@wr_webui_relay_ports", "masquerade", "comment", RELAY_RULE_TAG],
["add", "rule", "ip", "filter", "forward", "ip", "daddr", dst_ip, "udp", "dport", "@wr_webui_relay_ports", "accept", "comment", RELAY_RULE_TAG],
["add", "rule", "ip", "filter", "forward", "ip", "saddr", dst_ip, "udp", "sport", "@wr_webui_relay_ports", "accept", "comment", RELAY_RULE_TAG],
]
else:
rule_cmds = [
["add", "rule", "ip", "nat", "prerouting", "ip", "daddr", src_ip, "udp", "dport", str(relay_port), "dnat", "to", f"{dst_ip}:{target_port}", "comment", RELAY_RULE_TAG],
["add", "rule", "ip", "nat", "postrouting", "ip", "daddr", dst_ip, "udp", "dport", str(target_port), "masquerade", "comment", RELAY_RULE_TAG],
["add", "rule", "ip", "filter", "forward", "ip", "daddr", dst_ip, "udp", "dport", str(target_port), "accept", "comment", RELAY_RULE_TAG],
["add", "rule", "ip", "filter", "forward", "ip", "saddr", dst_ip, "udp", "sport", str(target_port), "accept", "comment", RELAY_RULE_TAG],
]
for args in rule_cmds:
res = _nft_result(args)
results.append(res)
critical.append(res)
failures = [r for r in critical if r.get("code") not in (0,)]
return failures, results
def _nft_result(args):
rc, out, err = _relay_nft(args)
return {"cmd": ["nft", *args], "code": rc, "stdout": out, "stderr": err}
def _apply_relay_iptables(src_ip: str, dst_ip: str, relay_port: int, target_port: int, mode: str):
results = []
if mode == "multiport":
for i in range(0, len(RELAY_MULTI_PORTS), 15):
ports_csv = ",".join(str(p) for p in RELAY_MULTI_PORTS[i:i + 15])
cmd_groups = [
["iptables", "-t", "nat", "-A", "PREROUTING", "-d", src_ip, "-p", "udp", "-m", "multiport", "--dports", ports_csv, "-j", "DNAT", "--to-destination", dst_ip, "-m", "comment", "--comment", RELAY_RULE_TAG],
["iptables", "-t", "nat", "-A", "POSTROUTING", "-p", "udp", "-d", dst_ip, "-m", "multiport", "--dports", ports_csv, "-j", "MASQUERADE", "-m", "comment", "--comment", RELAY_RULE_TAG],
["iptables", "-A", "FORWARD", "-p", "udp", "-d", dst_ip, "-m", "multiport", "--dports", ports_csv, "-j", "ACCEPT", "-m", "comment", "--comment", RELAY_RULE_TAG],
["iptables", "-A", "FORWARD", "-p", "udp", "-s", dst_ip, "-m", "multiport", "--sports", ports_csv, "-j", "ACCEPT", "-m", "comment", "--comment", RELAY_RULE_TAG],
]
for cmd in cmd_groups:
rc, out, err = run_cmd(cmd)
results.append({"cmd": cmd, "code": rc, "stdout": out, "stderr": err})
else:
cmd_groups = [
["iptables", "-t", "nat", "-A", "PREROUTING", "-d", src_ip, "-p", "udp", "--dport", str(relay_port), "-j", "DNAT", "--to-destination", f"{dst_ip}:{target_port}", "-m", "comment", "--comment", RELAY_RULE_TAG],
["iptables", "-t", "nat", "-A", "POSTROUTING", "-p", "udp", "-d", dst_ip, "--dport", str(target_port), "-j", "MASQUERADE", "-m", "comment", "--comment", RELAY_RULE_TAG],
["iptables", "-A", "FORWARD", "-p", "udp", "-d", dst_ip, "--dport", str(target_port), "-j", "ACCEPT", "-m", "comment", "--comment", RELAY_RULE_TAG],
["iptables", "-A", "FORWARD", "-p", "udp", "-s", dst_ip, "--sport", str(target_port), "-j", "ACCEPT", "-m", "comment", "--comment", RELAY_RULE_TAG],
]
for cmd in cmd_groups:
rc, out, err = run_cmd(cmd)
results.append({"cmd": cmd, "code": rc, "stdout": out, "stderr": err})
failures = [r for r in results if r.get("code") != 0]
return failures, results
def relay_rules_status() -> dict:
firewall = detect_relay_firewall()
state = load_relay_state()
if firewall == "nftables":
code, out, err = _relay_nft(["list", "ruleset"])
lines = [l.strip() for l in (out or "").splitlines() if RELAY_RULE_TAG in l or "wr_webui_relay_ports" in l]
return {"firewall": firewall, "tag": RELAY_RULE_TAG, "state": state, "enabled": bool(lines), "rules": lines, "code": code, "stderr": err}
if firewall == "iptables":
lines = []
for table_args in (["-t", "nat"], []):
code, out, err = run_cmd(["iptables", *table_args, "-S"])
if code == 0:
lines.extend([l for l in (out or "").splitlines() if RELAY_RULE_TAG in l])
return {"firewall": firewall, "tag": RELAY_RULE_TAG, "state": state, "enabled": bool(lines), "rules": lines}
return {"firewall": None, "tag": RELAY_RULE_TAG, "state": state, "enabled": False, "rules": [], "error": "nftables or iptables not found"}
def apply_relay_config(body: dict):
try:
mode = (body.get("mode") or "single").strip()
if mode not in ("single", "multiport"):
return 400, {"error": "mode must be single or multiport"}
endpoint_info = resolve_relay_endpoint(body.get("endpoint") or RELAY_DEFAULT_ENDPOINT)
src_ip = _valid_ipv4(body.get("sourceIp") or get_public_ipv4())
relay_port = _valid_port(body.get("relayPort") or 4500)
target_port = _valid_port(body.get("targetPort") or relay_port)
except Exception as e:
return 400, {"error": str(e)}
firewall = detect_relay_firewall()
if not firewall:
return 500, {"error": "nftables or iptables not found"}
clean = clean_relay_rules(firewall)
fwd_code, fwd_out, fwd_err = enable_relay_forwarding()
if firewall == "nftables":
failures, results = _apply_relay_nft(src_ip, endpoint_info["ip"], relay_port, target_port, mode)
else:
failures, results = _apply_relay_iptables(src_ip, endpoint_info["ip"], relay_port, target_port, mode)
save = _save_relay_firewall(firewall)
state = save_relay_state({
"enabled": not failures,
"mode": mode,
"endpoint": endpoint_info["endpoint"],
"endpointIp": endpoint_info["ip"],
"endpointSource": endpoint_info["source"],
"sourceIp": src_ip,
"relayPort": relay_port,
"targetPort": target_port,
"ports": RELAY_MULTI_PORTS if mode == "multiport" else [relay_port],
"tag": RELAY_RULE_TAG,
})
payload = {
"ok": not failures,
"firewall": firewall,
"clean": clean,
"forwarding": {"code": fwd_code, "stdout": fwd_out, "stderr": fwd_err},
"save": save,
"state": state,
"failures": failures[:8],
"result_count": len(results),
"status": relay_rules_status(),
}
log_event("info" if not failures else "error", "relay_apply", firewall=firewall, state=state, failures=len(failures))
return (200 if not failures else 500), payload
def remove_relay_config():
firewall = detect_relay_firewall()
clean = clean_relay_rules(firewall)
save = _save_relay_firewall(firewall) if firewall else {"code": 0}
state = load_relay_state()
state["enabled"] = False
state = save_relay_state(state)
log_event("info", "relay_removed", firewall=firewall)
return 200, {"ok": True, "firewall": firewall, "clean": clean, "save": save, "state": state, "status": relay_rules_status()}
def read_json_body(handler, max_bytes=16384):
try:
length = int(handler.headers.get("Content-Length", "0"))
except ValueError:
length = 0
if length > max_bytes:
raise ValueError("body too large")
raw = handler.rfile.read(length) if length else b""
if not raw:
return {}
return json.loads(raw.decode("utf-8"))
INDEX_HTML = r"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>WARP Web UI</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 24px; background:#0b1020; color:#e7e9ee; }
h2,h3 { margin: 0 0 10px 0; }
.row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
input, textarea, select { padding:8px 10px; border-radius:8px; border:1px solid #2a355a; background:#0b1020; color:#e7e9ee; min-width: 220px; }
textarea { width: 100%; min-height: 110px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
button { padding:10px 14px; border-radius:10px; border:1px solid #2a355a; background:#121a34; color:#e7e9ee; cursor:pointer; }
button:hover { background:#172145; }
button.secondary { background:#1a2448; }
button.danger { border-color:#7f1d1d; background:#3f1212; }
button:disabled { opacity:.55; cursor:not-allowed; }
.card { border:1px solid #2a355a; background:#0f1630; border-radius:14px; padding:14px; margin-top:14px; }
.kv { display:grid; grid-template-columns: 170px 1fr; gap:8px 12px; }
.k { color:#9aa7c7; }
pre { white-space:pre-wrap; word-break:break-word; margin:0; }
.muted { color:#9aa7c7; font-size: 12px; }
.ok { color:#6ee7b7; }
.bad { color:#fca5a5; }
.msg { margin-top:8px; font-size: 13px; }
.hint { color:#9aa7c7; font-size:12px; line-height:1.45; margin:8px 0 0 0; }
.split { display:grid; grid-template-columns: repeat(2, minmax(260px, 1fr)); gap:12px; margin-top:12px; }
.quick { padding:7px 10px; border-radius:8px; font-size:12px; }
@media (max-width: 760px) { .split { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h2>WARP Web UI</h2>
<div class="muted">Панель управления Cloudflare WARP. Авторизация Basic Auth. Автообновление каждые 4 секунды.</div>
<div class="card">
<div class="row">
<button id="btnConnect" title="Подключить WARP через warp-cli connect">Подключить WARP</button>
<button id="btnDisconnect" title="Отключить WARP через warp-cli disconnect">Отключить WARP</button>
<button id="btnRestart" title="Перезапустить системный сервис warp-svc">Перезапустить warp-svc</button>
<button id="btnRefresh" title="Обновить данные сейчас">Обновить</button>
<span id="statusBadge" class="muted"></span>
</div>
</div>
<div class="card">
<h3>Доступ к панели</h3>
<p class="hint">Здесь можно сменить логин и пароль веб-панели. Настройки сохраняются в <code>/etc/default/warp-webui</code>, затем сервис перезапускается. После сохранения браузер попросит войти заново.</p>
<div class="kv">
<div class="k">Текущий логин</div><div id="authUser">-</div>
<div class="k">Файл настроек</div><div id="authEnvFile">-</div>
</div>
<div class="row" style="margin-top:12px">
<input id="authUserInput" placeholder="Новый логин, например warpadmin" autocomplete="username"/>
<input id="authPassInput" type="password" placeholder="Новый пароль, минимум 8 символов" autocomplete="new-password"/>
<button id="btnAuthSave" class="secondary" title="Сохранить логин/пароль и перезапустить панель">Сохранить доступ</button>
</div>
<div id="authMsg" class="msg muted"></div>
</div>
<div class="card">
<h3>Аккаунт WARP</h3>
<p class="hint">Информация из <code>warp-cli registration show</code>. Если WARP ещё не установлен, здесь будет ошибка <code>command not found: warp-cli</code>.</p>
<div class="kv">
<div class="k">Тип аккаунта</div><div id="rType">-</div>
<div class="k">ID аккаунта</div><div id="rAccountId">-</div>
<div class="k">ID устройства</div><div id="rDeviceId">-</div>
<div class="k">Лицензия</div><div id="rLicense">-</div>
</div>
<div class="row" style="margin-top:12px">
<input id="licenseKey" placeholder="Ключ лицензии WARP+" autocomplete="off"/>
<button id="btnLicense" class="secondary" title="Применить ключ WARP+ через warp-cli">Применить лицензию</button>
</div>
<div id="accountMsg" class="msg muted"></div>
</div>
<div class="card">
<h3>Пакет Cloudflare WARP</h3>
<p class="hint">Установка ставит официальный пакет <code>cloudflare-warp</code> для Debian/Ubuntu. После установки можно подключить WARP и включить SOCKS proxy.</p>
<div class="row">
<button id="btnInstall" title="Установить официальный пакет cloudflare-warp">Установить WARP</button>
<button id="btnUninstall" class="danger" title="Удалить пакет cloudflare-warp">Удалить WARP</button>
</div>
<div id="installMsg" class="msg muted"></div>
</div>
<div class="card">
<h3>SOCKS proxy порт</h3>
<p class="hint">Режим <code>warp-cli mode proxy</code> поднимает локальный SOCKS на <code>127.0.0.1:порт</code>. Его используют 3x-ui, Amnezia и другие клиенты на этом сервере.</p>
<div class="kv">
<div class="k">Текущий порт</div><div id="proxyPort">-</div>
<div class="k">Локальный адрес</div><div id="proxyEndpoint">-</div>
</div>
<div class="row" style="margin-top:12px">
<input id="proxyPortInput" type="number" min="1" max="65535" placeholder="например 40000"/>
<button id="btnSetPort" class="secondary" title="Сменить порт SOCKS proxy">Сохранить порт</button>
<button id="btnPort40000" class="secondary" title="Быстро поставить порт 40000">Поставить 40000</button>
</div>
<div id="proxyMsg" class="msg muted"></div>
</div>
<div class="card">
<h3>Маршрутизация WARP</h3>
<p class="hint">Список ниже отправляется через WARP SOCKS в Amnezia/Xray. Домены работают как <code>geosite:google</code>, <code>openai.com</code>, <code>domain:example.com</code>; IP работают как <code>1.1.1.1</code>, <code>8.8.8.0/24</code>, <code>geoip:netflix</code>. Бесплатный WARP не даёт выбор страны, но позволяет вывести выбранные сайты через WARP.</p>
<div class="kv">
<div class="k">Состояние</div><div id="warpRoutesEnabled">-</div>
<div class="k">Файл списка</div><div id="warpRoutesPath">-</div>
<div class="k">Прямой IP</div><div id="warpRouteDirectIp">-</div>
<div class="k">IP через WARP</div><div id="warpRouteWarpIp">-</div>
</div>
<div class="split">
<label>
<div class="muted">Домены и geosite</div>
<textarea id="warpRouteDomains" placeholder="geosite:google&#10;openai.com&#10;youtube.com"></textarea>
</label>
<label>
<div class="muted">IP и geoip</div>
<textarea id="warpRouteIps" placeholder="1.1.1.1&#10;8.8.8.0/24&#10;geoip:private"></textarea>
</label>
</div>
<div class="row" style="margin-top:10px">
<button id="btnRoutePresetGoogle" class="secondary quick" title="Добавить geosite:google">Google</button>
<button id="btnRoutePresetOpenAI" class="secondary quick" title="Добавить домены OpenAI">OpenAI</button>
<button id="btnRoutePresetYouTube" class="secondary quick" title="Добавить YouTube">YouTube</button>
<button id="btnRouteSave" class="secondary" title="Сохранить список без изменения Xray">Сохранить список</button>
<button id="btnRouteEnable" class="secondary" title="Применить список в Amnezia/Xray через outbound warp-socks">Включить в Amnezia/Xray</button>
<button id="btnRouteDisable" class="danger" title="Убрать глобальные правила warp-socks из Amnezia/Xray">Выключить правила</button>
<button id="btnRouteTest" title="Проверить внешний IP напрямую и через WARP SOCKS">Тест IP</button>
</div>
<div id="warpRouteMsg" class="msg muted"></div>
</div>
<div class="card">
<h3>WARP Relay (beta)</h3>
<p class="hint">Relay перенаправляет UDP WireGuard/WARP трафик через firewall rules. Это не гарантирует конкретную страну Cloudflare, но позволяет выбрать endpoint/IP, к которому будет идти подключение.</p>
<div class="kv">
<div class="k">Firewall</div><div id="relayFirewall">-</div>
<div class="k">Активен</div><div id="relayEnabled">-</div>
<div class="k">IP endpoint</div><div id="relayEndpointIp">-</div>
<div class="k">Правил</div><div id="relayRuleCount">-</div>
</div>
<div class="row" style="margin-top:12px">
<input id="relayEndpoint" placeholder="engage.cloudflareclient.com или IPv4" title="Целевой WARP/WireGuard endpoint"/>
<input id="relaySourceIp" placeholder="публичный IPv4, пусто = авто" title="IP этого сервера, на который приходит UDP трафик"/>
<select id="relayMode">
<option value="single">Один UDP порт</option>
<option value="multiport">Cloudflare WARP multiport</option>
</select>
<input id="relayPort" type="number" min="1" max="65535" placeholder="порт relay"/>
<input id="relayTargetPort" type="number" min="1" max="65535" placeholder="порт endpoint"/>
</div>
<div class="row" style="margin-top:12px">
<button id="btnRelayApply" class="secondary" title="Создать firewall rules только с тегом WR_WEBUI_RELAY">Включить relay</button>
<button id="btnRelayRemove" class="danger" title="Удалить только managed rules с тегом WR_WEBUI_RELAY">Удалить relay rules</button>
</div>
<div id="relayMsg" class="msg muted"></div>
<pre id="relayRules" style="margin-top:10px"></pre>
</div>
<div class="card">
<h3>3x-ui / Xray</h3>
<p class="hint">Добавляет outbound <code>warp-socks</code> на <code>127.0.0.1:PORT</code> и правило маршрутизации <code>geosite:google</code>. Перед изменением создаётся backup конфига.</p>
<button id="btnXui" class="secondary">Применить preset к x-ui</button>
<div id="xuiMsg" class="msg muted"></div>
</div>
<div class="card">
<h3>Amnezia Xray (Docker)</h3>
<p class="muted">Мост <code>172.17.0.1:11025</code> → SOCKS на хосте. В маршрутизации Xray по-прежнему UUID/email; в UI — понятные имена (алиасы: <code>/etc/warp-webui/client-aliases.json</code>).</p>
<button id="btnAmnezia" class="secondary">Применить preset к Amnezia (все клиенты, geosite:google)</button>
<div id="amneziaMsg" class="msg muted"></div>
<h4 style="margin-top:16px">WARP только для выбранных клиентов</h4>
<p class="muted">Отметьте клиентов по имени. Ничего не выбрано → общее правило geosite. Имя можно сохранить — переживёт перезапуск UI.</p>
<div id="amneziaClientList" class="muted">Загрузка клиентов...</div>
<div class="row" style="margin-top:8px">
<input id="amneziaDomains" placeholder="домены через запятую, по умолчанию geosite:google" style="flex:1"/>
</div>
<button id="btnAmneziaRouting" class="secondary" style="margin-top:8px">Применить WARP-маршрутизацию для выбранных</button>
<div id="amneziaRoutingMsg" class="msg muted"></div>
</div>
<div class="card">
<h3>Готовые пресеты для клиентов (JSON)</h3>
<p class="hint">Можно скопировать JSON и использовать как подсказку при настройке Xray/v2rayN/Amnezia.</p>
<textarea id="presetsJson" readonly></textarea>
<button id="btnCopyPresets" class="secondary" style="margin-top:8px">Скопировать JSON</button>
</div>
<div class="card">
<h3>Статус WARP</h3>
<div class="kv">
<div class="k">Подключен</div><div id="sConnected">-</div>
<div class="k">Health</div><div id="sHealth">-</div>
<div class="k">Аккаунт</div><div id="sAccount">-</div>
<div class="k">Устройство</div><div id="sDevice">-</div>
<div class="k">Последний stderr</div><div><pre id="sStderr"></pre></div>
<div class="k">Последний stdout</div><div><pre id="sStdout"></pre></div>
</div>
</div>
<div class="card">
<h3>Последние события</h3>
<div class="muted" id="logMeta"></div>
<pre id="logText" style="margin-top:10px"></pre>
</div>
<script>
const el = (id) => document.getElementById(id);
let authRestartUntil = 0;
function badge(text, ok=null) {
const b = el('statusBadge');
b.textContent = text;
b.className = ok === true ? 'ok' : ok === false ? 'bad' : 'muted';
}
function setText(id, v) {
el(id).textContent = (v === null || v === undefined || v === '') ? '-' : String(v);
}
function setMsg(id, text, ok=null) {
const n = el(id);
n.textContent = text || '';
n.className = 'msg ' + (ok === true ? 'ok' : ok === false ? 'bad' : 'muted');
}
async function api(path, opts={}) {
const url = new URL(path, window.location.origin);
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));
return body;
}
async function apiPost(path, payload) {
return api(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}) });
}
async function refreshAuthConfig() {
try {
const cfg = await api('/auth-config');
setText('authUser', cfg.user);
setText('authEnvFile', cfg.env_file);
if (!el('authUserInput').value) el('authUserInput').value = cfg.user || '';
} catch (e) {
if (Date.now() < authRestartUntil) {
setMsg('authMsg', 'Панель перезапускается, через пару секунд обновится...', null);
} else {
setMsg('authMsg', 'Не удалось загрузить настройки доступа: ' + (e.message || e), false);
}
}
}
async function refreshStatus() {
try {
const data = await api('/status');
setText('sConnected', data.connected);
setText('sHealth', data.health);
setText('sAccount', data.account);
setText('sDevice', data.device);
setText('sStdout', (data.stdout || '').slice(-4000));
setText('sStderr', (data.stderr || '').slice(-4000));
if (data.connected === true) badge('WARP подключен', true);
else if (data.connected === false) badge('WARP отключен', false);
else badge('Статус неизвестен');
el('btnConnect').disabled = data.connected === true;
el('btnDisconnect').disabled = data.connected === false;
} catch (e) { badge('Ошибка /status', false); }
}
async function refreshRegistration() {
try {
const r = await api('/registration');
setText('rType', r.account_type);
setText('rAccountId', r.account_id);
setText('rDeviceId', r.device_id);
setText('rLicense', r.license_masked);
} catch (e) { setMsg('accountMsg', 'Не удалось загрузить регистрацию WARP', false); }
}
async function refreshProxy() {
try {
const p = await api('/proxy');
setText('proxyPort', p.port);
setText('proxyEndpoint', p.port ? ('127.0.0.1:' + p.port) : '-');
if (p.port && !el('proxyPortInput').value) el('proxyPortInput').value = p.port;
} catch (e) { setMsg('proxyMsg', 'Не удалось загрузить SOCKS порт', false); }
}
async function refreshPresets() {
try {
const p = await api('/presets');
el('presetsJson').value = JSON.stringify(p, null, 2);
} catch (e) { el('presetsJson').value = 'Не удалось загрузить пресеты'; }
}
async function refreshRelay() {
try {
const r = await api('/relay');
const st = r.state || {};
setText('relayFirewall', r.firewall);
setText('relayEnabled', r.enabled);
setText('relayEndpointIp', st.endpointIp);
setText('relayRuleCount', (r.rules || []).length);
if (!el('relayEndpoint').value) el('relayEndpoint').value = st.endpoint || 'engage.cloudflareclient.com';
if (!el('relaySourceIp').value) el('relaySourceIp').value = st.sourceIp || '';
if (!el('relayPort').value) el('relayPort').value = st.relayPort || 4500;
if (!el('relayTargetPort').value) el('relayTargetPort').value = st.targetPort || 4500;
el('relayMode').value = st.mode || 'single';
el('relayRules').textContent = (r.rules || []).join('\n');
} catch (e) { setMsg('relayMsg', 'Не удалось загрузить статус relay: ' + (e.message || e), false); }
}
function listFromTextarea(id) {
return (el(id).value || '').split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean);
}
function appendUniqueLines(id, lines) {
const existing = listFromTextarea(id);
const seen = new Set(existing.map((s) => s.toLowerCase()));
lines.forEach((line) => {
if (!seen.has(String(line).toLowerCase())) {
existing.push(line);
seen.add(String(line).toLowerCase());
}
});
el(id).value = existing.join('\n');
}
function warpRoutesPayload() {
return {
domains: listFromTextarea('warpRouteDomains'),
ips: listFromTextarea('warpRouteIps'),
};
}
function routeTestLabel(test) {
if (!test) return '-';
if (test.ip) return test.ip;
if (test.error) return 'ошибка: ' + String(test.error).slice(0, 120);
return '-';
}
async function refreshWarpRoutes() {
try {
const r = await api('/warp-routes');
setText('warpRoutesEnabled', r.enabled ? 'включено' : 'выключено');
setText('warpRoutesPath', r.path);
if (!el('warpRouteDomains').value) el('warpRouteDomains').value = (r.domains || []).join('\n');
if (!el('warpRouteIps').value) el('warpRouteIps').value = (r.ips || []).join('\n');
const last = r.last_test || {};
setText('warpRouteDirectIp', routeTestLabel(last.direct));
setText('warpRouteWarpIp', routeTestLabel(last.warp));
} catch (e) {
setMsg('warpRouteMsg', 'Не удалось загрузить маршруты WARP: ' + (e.message || e), false);
}
}
async function refreshLogs() {
try {
const data = await api('/logs');
const lines = (data.entries || []).map(e => {
const ts = e.ts || '';
const lvl = e.level || '';
const msg = e.message || '';
const action = e.action ? ` action=${e.action}` : '';
const rc = (e.returncode !== undefined) ? ` rc=${e.returncode}` : '';
return `${ts} ${lvl} ${msg}${action}${rc}`;
});
el('logMeta').textContent = `событий: ${lines.length}`;
el('logText').textContent = lines.join('\n');
} catch (e) { el('logMeta').textContent = 'не удалось загрузить события'; }
}
async function refreshAll() {
await Promise.all([refreshAuthConfig(), refreshStatus(), refreshRegistration(), refreshProxy(), refreshPresets(), refreshRelay(), refreshWarpRoutes(), refreshLogs(), refreshAmneziaClients()]);
}
async function doAction(path) {
badge('Выполняю...', null);
try { await api(path, { method: 'POST' }); badge('OK ' + path, true); }
catch (e) { badge('Ошибка ' + path, false); }
await refreshAll();
}
el('btnConnect').onclick = () => doAction('/connect');
el('btnDisconnect').onclick = () => doAction('/disconnect');
el('btnRestart').onclick = () => doAction('/restart');
el('btnRefresh').onclick = () => refreshAll();
el('btnAuthSave').onclick = async () => {
const user = el('authUserInput').value.trim();
const password = el('authPassInput').value;
if (!user) return setMsg('authMsg', 'Введите новый логин', false);
if (password && password.length < 8) return setMsg('authMsg', 'Пароль должен быть минимум 8 символов', false);
if (!confirm('Сохранить новый доступ и перезапустить панель? После этого нужно войти заново.')) return;
setMsg('authMsg', 'Сохраняю доступ и перезапускаю сервис...', null);
try {
const payload = { user };
if (password) payload.password = password;
const r = await apiPost('/auth-config', payload);
el('authPassInput').value = '';
authRestartUntil = Date.now() + 10000;
setMsg('authMsg', r.note || 'Сохранено. Войдите заново.', true);
} catch (e) { setMsg('authMsg', String(e.message || e), false); }
};
el('btnLicense').onclick = async () => {
const key = el('licenseKey').value.trim();
setMsg('accountMsg', 'Применяю лицензию...', null);
try {
const r = await apiPost('/license', { key });
setMsg('accountMsg', (r.stderr || r.stdout || 'Лицензия применена').slice(0, 500), true);
el('licenseKey').value = '';
await refreshRegistration();
} catch (e) { setMsg('accountMsg', String(e.message || e), false); }
};
el('btnInstall').onclick = async () => {
if (!confirm('Установить пакет Cloudflare WARP на этот сервер?')) return;
setMsg('installMsg', 'Запускаю установку WARP...', null);
try {
const r = await apiPost('/warp-install', {});
setMsg('installMsg', (r.stdout || r.stderr || 'Готово').slice(0, 800), r.result_code === 0);
await refreshAll();
} catch (e) { setMsg('installMsg', String(e.message || e), false); }
};
el('btnUninstall').onclick = async () => {
if (!confirm('Удалить пакет cloudflare-warp?')) return;
setMsg('installMsg', 'Запускаю удаление WARP...', null);
try {
const r = await apiPost('/warp-uninstall', {});
setMsg('installMsg', (r.stdout || r.stderr || 'Готово').slice(0, 800), r.result_code === 0);
} catch (e) { setMsg('installMsg', String(e.message || e), false); }
};
async function setPort(port) {
setMsg('proxyMsg', 'Сохраняю порт ' + port + '...', null);
try {
const r = await apiPost('/proxy-port', { port });
setMsg('proxyMsg', 'Порт обновлен: ' + (r.proxy && r.proxy.port), true);
await refreshProxy();
await refreshPresets();
} catch (e) { setMsg('proxyMsg', String(e.message || e), false); }
}
el('btnSetPort').onclick = () => {
const p = parseInt(el('proxyPortInput').value, 10);
if (!p) return setMsg('proxyMsg', 'Введите корректный порт', false);
setPort(p);
};
el('btnPort40000').onclick = () => { el('proxyPortInput').value = 40000; setPort(40000); };
el('btnRelayApply').onclick = async () => {
if (!confirm('Включить WARP Relay и применить firewall rules на сервере?')) return;
const payload = {
endpoint: el('relayEndpoint').value.trim() || 'engage.cloudflareclient.com',
sourceIp: el('relaySourceIp').value.trim(),
mode: el('relayMode').value,
relayPort: parseInt(el('relayPort').value || '4500', 10),
targetPort: parseInt(el('relayTargetPort').value || el('relayPort').value || '4500', 10),
};
setMsg('relayMsg', 'Применяю relay rules...', null);
try {
const r = await apiPost('/relay-apply', payload);
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); }
};
el('btnRelayRemove').onclick = async () => {
if (!confirm('Удалить только WARP Web UI relay rules?')) return;
setMsg('relayMsg', 'Удаляю relay rules...', null);
try {
await apiPost('/relay-remove', {});
setMsg('relayMsg', 'Relay rules удалены', true);
await refreshRelay();
} catch (e) { setMsg('relayMsg', String(e.message || e), false); }
};
el('btnRoutePresetGoogle').onclick = () => appendUniqueLines('warpRouteDomains', ['geosite:google']);
el('btnRoutePresetOpenAI').onclick = () => appendUniqueLines('warpRouteDomains', ['openai.com', 'chatgpt.com', 'oaistatic.com', 'oaiusercontent.com']);
el('btnRoutePresetYouTube').onclick = () => appendUniqueLines('warpRouteDomains', ['geosite:youtube']);
el('btnRouteSave').onclick = async () => {
setMsg('warpRouteMsg', 'Сохраняю список...', null);
try {
const r = await apiPost('/warp-routes-save', warpRoutesPayload());
const st = r.state || {};
setMsg('warpRouteMsg', 'Список сохранен: доменов ' + ((st.domains || []).length) + ', IP ' + ((st.ips || []).length), true);
await refreshWarpRoutes();
} catch (e) { setMsg('warpRouteMsg', String(e.message || e), false); }
};
el('btnRouteEnable').onclick = async () => {
if (!confirm('Включить эти маршруты в Amnezia/Xray? Будет backup server.json и restart контейнера.')) return;
setMsg('warpRouteMsg', 'Применяю маршруты в Amnezia/Xray...', null);
try {
const r = await apiPost('/warp-routes-enable', warpRoutesPayload());
setMsg('warpRouteMsg', 'Маршруты включены; restart rc=' + r.restart_code, r.ok);
await refreshWarpRoutes();
await refreshAmneziaClients();
} catch (e) { setMsg('warpRouteMsg', String(e.message || e), false); }
};
el('btnRouteDisable').onclick = async () => {
if (!confirm('Выключить глобальные правила WARP в Amnezia/Xray? Список останется сохраненным.')) return;
setMsg('warpRouteMsg', 'Выключаю правила WARP...', null);
try {
const r = await apiPost('/warp-routes-disable', {});
setMsg('warpRouteMsg', 'Правила выключены; restart rc=' + r.restart_code, r.ok);
await refreshWarpRoutes();
await refreshAmneziaClients();
} catch (e) { setMsg('warpRouteMsg', String(e.message || e), false); }
};
el('btnRouteTest').onclick = async () => {
setMsg('warpRouteMsg', 'Проверяю внешний IP напрямую и через WARP SOCKS...', null);
try {
const r = await apiPost('/warp-routes-test', {});
setText('warpRouteDirectIp', routeTestLabel(r.direct));
setText('warpRouteWarpIp', routeTestLabel(r.warp));
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); }
};
el('btnXui').onclick = async () => {
setMsg('xuiMsg', 'Применяю preset x-ui...', null);
try {
const r = await apiPost('/xui-preset', {});
setMsg('xuiMsg', 'Backup: ' + (r.backup || '-') + '; код перезапуска=' + r.restart_code, r.restart_code === 0);
} catch (e) { setMsg('xuiMsg', String(e.message || e), false); }
};
async function refreshAmneziaClients() {
const box = el('amneziaClientList');
try {
const data = await api('/amnezia-clients');
const clients = data.clients || [];
if (!clients.length) {
box.innerHTML = '<span class="muted">Нет VLESS-клиентов в server.json</span>';
return;
}
box.innerHTML = clients.map((c) => {
const routing = c.routingUser || c.email || c.uuid || c.id;
const name = c.displayName || routing;
const short = c.shortUuid || routing;
const src = c.source === 'alias' ? 'алиас' : (c.source === 'email' ? 'email' : (c.source === 'comment' ? 'comment' : 'uuid'));
const escRoute = String(routing).replace(/"/g, '&quot;');
const escUuid = String(c.uuid || c.id).replace(/"/g, '&quot;');
const escName = String(name).replace(/"/g, '&quot;');
return '<div style="margin:8px 0;padding:6px 0;border-bottom:1px solid #2a2a2a">' +
'<label><input type="checkbox" class="amz-client" value="' + escRoute + '"/> ' +
'<strong>' + escName + '</strong> <span class="muted">(' + short + ', ' + src + ')</span></label>' +
'<div class="row" style="margin-top:4px;gap:6px">' +
'<input class="amz-alias" data-uuid="' + escUuid + '" value="' + escName + '" style="flex:1" placeholder="Имя в интерфейсе"/>' +
'<button type="button" class="secondary amz-save-alias" data-uuid="' + escUuid + '">Сохранить имя</button>' +
'</div></div>';
}).join('');
box.querySelectorAll('.amz-save-alias').forEach((btn) => {
btn.onclick = async () => {
const uuid = btn.getAttribute('data-uuid');
const inp = box.querySelector('.amz-alias[data-uuid="' + uuid + '"]');
const displayName = inp ? inp.value.trim() : '';
if (!displayName) return setMsg('amneziaRoutingMsg', 'Введите имя', false);
try {
await apiPost('/amnezia-client-alias', { uuid, displayName });
setMsg('amneziaRoutingMsg', 'Имя сохранено', true);
await refreshAmneziaClients();
} catch (e) { setMsg('amneziaRoutingMsg', String(e.message || e), false); }
};
});
} catch (e) {
box.textContent = 'Не удалось загрузить клиентов: ' + (e.message || e);
}
}
el('btnAmnezia').onclick = async () => {
setMsg('amneziaMsg', 'Применяю preset Amnezia...', null);
try {
const r = await apiPost('/amnezia-preset', {});
const names = (r.clients_display || []).join(', ');
setMsg('amneziaMsg', (names ? ('Клиенты: ' + names + '. ') : '') + 'restart rc=' + r.restart_code, r.restart_code === 0);
} catch (e) { setMsg('amneziaMsg', String(e.message || e), false); }
};
el('btnAmneziaRouting').onclick = async () => {
const users = Array.from(document.querySelectorAll('.amz-client:checked')).map((n) => n.value);
const domRaw = (el('amneziaDomains').value || '').trim();
const domains = domRaw ? domRaw.split(',').map((s) => s.trim()).filter(Boolean) : ['geosite:google'];
setMsg('amneziaRoutingMsg', users.length ? ('Применяю для ' + users.length + ' клиент(ов)...') : 'Применяю общее правило geosite...', null);
try {
const r = await apiPost('/amnezia-routing', { users, domains });
const who = (r.users_display && r.users_display.length) ? r.users_display.join(', ') : 'все (geosite)';
setMsg('amneziaRoutingMsg', 'OK: ' + who + '; rules=' + ((r.routing_rules && r.routing_rules.length) || 0), r.ok);
await refreshAmneziaClients();
} catch (e) { setMsg('amneziaRoutingMsg', String(e.message || e), false); }
};
el('btnCopyPresets').onclick = async () => {
try { await navigator.clipboard.writeText(el('presetsJson').value); setMsg('accountMsg', 'Presets copied', true); }
catch (e) { setMsg('accountMsg', 'Не удалось скопировать', false); }
};
refreshAll();
setInterval(refreshAll, 4000);
</script>
</body>
</html>"""
class Handler(BaseHTTPRequestHandler):
def _auth_ok(self):
header = self.headers.get("Authorization", "")
if not header.startswith("Basic "):
return False
token = header.split(" ", 1)[1].strip()
creds = USER + ":" + PASS
expected = base64.b64encode(creds.encode()).decode()
return token == expected
def _unauthorized(self, content_type="application/json"):
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="warp-webui"')
self.send_header("Content-Type", content_type)
self.end_headers()
if content_type == "application/json":
self.wfile.write(json.dumps({"error": "unauthorized"}).encode())
else:
self.wfile.write(b"unauthorized")
def _json(self, code, payload):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(payload, ensure_ascii=True).encode())
def _html(self, code, html: str):
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(html.encode("utf-8"))
def do_GET(self):
if not self._auth_ok():
return self._unauthorized(content_type="text/plain")
if self.path == "/":
log_event("info", "ui_loaded", client=self.client_address[0])
return self._html(200, INDEX_HTML)
if self.path == "/status":
return self._json(200, warp_status())
if self.path == "/registration":
return self._json(200, warp_registration())
if self.path == "/proxy":
return self._json(200, get_proxy_port())
if self.path == "/auth-config":
return self._json(200, auth_config())
if self.path == "/relay":
return self._json(200, relay_rules_status())
if self.path == "/warp-routes":
return self._json(200, load_warp_routes())
if self.path == "/amnezia-clients":
clients, err = list_amnezia_clients()
if clients is None:
return self._json(500, {"error": "read failed", "detail": err})
warp_rules = []
cfg, _ = _read_amnezia_config()
if cfg:
warp_rules = [r for r in cfg.get("routing", {}).get("rules", []) if r.get("outboundTag") == SOCKS_OUTBOUND_TAG]
return self._json(200, {
"clients": clients,
"warp_rules": warp_rules,
"aliases_path": CLIENT_ALIASES_PATH,
})
if self.path == "/presets":
p = get_proxy_port().get("port") or 1024
return self._json(200, client_presets(int(p)))
if self.path == "/logs":
return self._json(200, {"entries": list(LOG_BUFFER)})
return self._json(404, {"error": "not found"})
def do_POST(self):
if not self._auth_ok():
return self._unauthorized()
log_event("info", "action_requested", action=self.path, client=self.client_address[0])
action_map = {
"/connect": ["warp-cli", "--accept-tos", "connect"],
"/disconnect": ["warp-cli", "--accept-tos", "disconnect"],
"/restart": ["systemctl", "restart", "warp-svc"],
}
if self.path in action_map:
code, out, err = run_cmd(action_map[self.path])
if self.path == "/restart":
time.sleep(1.0)
payload = {
"action": self.path,
"result_code": code,
"stdout": out,
"stderr": err,
"status": warp_status(),
}
return self._json(200 if code == 0 else 500, payload)
try:
body = read_json_body(self)
except Exception as e:
return self._json(400, {"error": str(e)})
if self.path == "/license":
code, payload = apply_license_key(body.get("key", ""))
return self._json(code, payload)
if self.path == "/auth-config":
code, payload = update_webui_credentials(
body.get("user", ""),
body.get("password") if "password" in body else None,
)
return self._json(code, payload)
if self.path == "/proxy-port":
try:
port = int(body.get("port"))
except (TypeError, ValueError):
return self._json(400, {"error": "port required"})
code, payload = set_proxy_port(port)
return self._json(code, payload)
if self.path == "/warp-install":
port = int(os.environ.get("WARP_PROXY_PORT") or 0) or get_proxy_port().get("port") or 1024
code, payload = run_script(INSTALL_SCRIPT, {"WARP_PROXY_PORT": str(port)})
payload["status"] = warp_status()
return self._json(code, payload)
if self.path == "/warp-uninstall":
code, payload = run_script(UNINSTALL_SCRIPT)
return self._json(code, payload)
if self.path == "/relay-apply":
code, payload = apply_relay_config(body)
return self._json(code, payload)
if self.path == "/relay-remove":
code, payload = remove_relay_config()
return self._json(code, payload)
if self.path == "/warp-routes-save":
domains, ips = split_domain_ip_routes(
domains=body.get("domains"),
ips=body.get("ips"),
mixed=body.get("routes"),
)
state = save_warp_routes(domains, ips)
return self._json(200, {"ok": True, "state": state})
if self.path == "/warp-routes-enable":
domains, ips = split_domain_ip_routes(
domains=body.get("domains"),
ips=body.get("ips"),
mixed=body.get("routes"),
)
code, payload = apply_warp_routes_to_amnezia(domains, ips)
return self._json(code, payload)
if self.path == "/warp-routes-disable":
code, payload = disable_warp_routes_in_amnezia()
return self._json(code, payload)
if self.path == "/warp-routes-test":
code, payload = test_warp_route_ips()
return self._json(code, payload)
if self.path == "/xui-preset":
port = get_proxy_port().get("port") or 1024
code, payload = apply_xui_preset(int(port))
return self._json(code, payload)
if self.path == "/amnezia-routing":
port = get_proxy_port().get("port") or 1024
users = body.get("users")
if users is not None and not isinstance(users, list):
users = [users]
domains = body.get("domains")
if domains is not None and not isinstance(domains, list):
domains = [domains]
code, payload = apply_amnezia_routing(int(port), users=users, domains=domains)
return self._json(code, payload)
if self.path == "/amnezia-preset":
port = get_proxy_port().get("port") or 1024
code, payload = apply_amnezia_preset(int(port))
return self._json(code, payload)
if self.path == "/amnezia-client-alias":
uuid_val = (body.get("uuid") or body.get("id") or "").strip()
display = (body.get("displayName") or body.get("name") or "").strip()
if not _is_uuid_like(uuid_val):
return self._json(400, {"error": "valid uuid required"})
if not display:
return self._json(400, {"error": "displayName required"})
aliases = load_client_aliases()
aliases[uuid_val.lower()] = display
saved = save_client_aliases(aliases)
log_event("info", "client_alias_saved", uuid=uuid_val, displayName=display)
return self._json(200, {"uuid": uuid_val, "displayName": display, "aliases": saved})
return self._json(404, {"error": "not found"})
def log_message(self, _format, *args):
return
if __name__ == "__main__":
log_event("info", "service_start", host=HOST, port=PORT)
HTTPServer((HOST, PORT), Handler).serve_forever()