#!/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")) 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") 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 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 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): info = {"raw": stdout, "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 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) start = time.time() proc = subprocess.run([path], shell=False, capture_output=True, text=True, timeout=600, env=env) dur_ms = int((time.time() - start) * 1000) log_event( "info", "script_executed", path=path, 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, "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 _strip_managed_warp_rules(rules): kept = [] for r in rules: if r.get("outboundTag") != SOCKS_OUTBOUND_TAG: kept.append(r) 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): routing = cfg.setdefault("routing", {}) rules = routing.setdefault("rules", []) rules = _strip_managed_warp_rules(rules) domains = [d for d in (domains or ["geosite:google"]) if d] users = [u for u in (users or []) if u] if users: for u in users: rules.append({ "type": "field", "user": [u], "domain": domains, "outboundTag": SOCKS_OUTBOUND_TAG, }) else: rules.append({ "type": "field", "domain": domains, "outboundTag": SOCKS_OUTBOUND_TAG, }) 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 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("cannot detect public IPv4; set sourceIp manually") def detect_relay_firewall(): if shutil.which("nft"): 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"""
Adds outbound tag warp-socks → 127.0.0.1:PORT and routing rule geosite:google. Backs up config, restarts x-ui.
Мост 172.17.0.1:11025 → SOCKS на хосте. В маршрутизации Xray по-прежнему UUID/email; в UI — понятные имена (алиасы: /etc/warp-webui/client-aliases.json).
Отметьте клиентов по имени. Ничего не выбрано → общее правило geosite. Имя можно сохранить — переживёт перезапуск UI.