mirror of
https://github.com/andrey271192/WARP-Web-UI-Relay-Beta.git
synced 2026-09-20 11:55:35 +00:00
2077 lines
88 KiB
Python
Executable File
2077 lines
88 KiB
Python
Executable File
#!/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
|
||
"""
|
||
try:
|
||
with open(unit_path, "w", encoding="utf-8") as f:
|
||
f.write(unit)
|
||
except OSError as e:
|
||
log_event("error", "socks_bridge_write_failed", unit=unit_path, error=str(e))
|
||
return {
|
||
"unit": unit_path,
|
||
"bridge": f"{BRIDGE_HOST}:{BRIDGE_PORT}",
|
||
"target": f"127.0.0.1:{target_port}",
|
||
"ok": False,
|
||
"error": str(e),
|
||
}
|
||
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}",
|
||
"ok": c1 == 0 and c2 == 0,
|
||
"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 openai.com youtube.com"></textarea>
|
||
</label>
|
||
<label>
|
||
<div class="muted">IP и geoip</div>
|
||
<textarea id="warpRouteIps" placeholder="1.1.1.1 8.8.8.0/24 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, '"');
|
||
const escUuid = String(c.uuid || c.id).replace(/"/g, '"');
|
||
const escName = String(name).replace(/"/g, '"');
|
||
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()
|