feat: add warp relay beta

This commit is contained in:
Андрей Бобырев
2026-06-05 17:51:38 +03:00
parent 1dbe715067
commit 7e8edae777
2 changed files with 467 additions and 2 deletions

63
BETA_WARP_RELAY.md Normal file
View File

@@ -0,0 +1,63 @@
# WARP Relay beta note
## What `openwarpkit/warp-relay` adds
`warp-relay` configures Linux firewall NAT/forwarding for UDP WireGuard/WARP traffic:
- relay server listens on public/local IP and UDP port;
- firewall DNAT sends traffic to chosen WireGuard/WARP endpoint IP/port;
- MASQUERADE hides client source behind relay server;
- optional multiport mode forwards known Cloudflare WARP UDP ports to same ports on target endpoint.
It does not change Cloudflare WARP exit IP directly. It changes path to WARP/WireGuard endpoint. Desired "connection point" in beta means selecting target endpoint hostname/IP and port from Web UI, then applying relay firewall rules.
## Beta implementation
Added built-in relay manager to `app.py`:
- `GET /relay` shows firewall type, saved relay state, active managed rules;
- `POST /relay-apply` applies relay rules for `single` or `multiport` mode;
- `POST /relay-remove` removes only rules tagged `WR_WEBUI_RELAY`;
- UI card "WARP Relay (beta)" controls endpoint, source IP, mode, relay port, target port.
State file: `/etc/warp-webui/relay-state.json`
Rule tag: `WR_WEBUI_RELAY`
Firewall support:
- preferred: `nftables`;
- fallback: `iptables`;
- no automatic package install from UI in beta.
## Safety choices
- Does not run `wr.sh` directly.
- Does not delete upstream `WR_RULE` rules.
- Does not disable `net.ipv4.ip_forward` on remove, because other VPN/NAT services can depend on it.
- Cleans only own tag before applying new rules.
- Saves nftables to `/etc/nftables.conf`; for iptables persists via `netfilter-persistent` if installed.
## Beta testing path
1. Install beta on clean VPS or non-critical host.
2. Open WARP Web UI.
3. Use relay endpoint `engage.cloudflareclient.com`, mode `single`, port `4500`.
4. Test from client WireGuard/WARP config pointing to `SERVER_IP:4500`.
5. Try multiport only after single-port test works.
6. For custom "point", enter target IPv4 or hostname manually.
## Known limits
- Cloudflare WARP public exit IP/region is still controlled by Cloudflare.
- Endpoint DNS can resolve to different IPs over time; re-apply rules after changing target.
- Multiport mode maps relay port X to target port X only.
- UI does not yet benchmark/list candidate WARP endpoints by latency/country.
## Next beta features
- Endpoint library with labels such as "Cloudflare default", "custom IP", "last known fast IP".
- Latency probe for candidate endpoints.
- Client config generator replacing endpoint with relay host/port.
- Read-only dry-run preview before apply.
- Separate beta GitHub repo or `beta/warp-relay` branch once GitHub auth/token is available.

406
app.py
View File

@@ -1,9 +1,12 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import base64 import base64
import ipaddress
import json import json
import os import os
import re import re
import shlex
import shutil import shutil
import socket
import subprocess import subprocess
import time import time
from collections import deque from collections import deque
@@ -34,6 +37,17 @@ CLIENT_ALIASES_PATH = os.environ.get("WARP_CLIENT_ALIASES", "/etc/warp-webui/cli
BRIDGE_HOST = os.environ.get("WARP_SOCKS_BRIDGE_HOST", "172.17.0.1") BRIDGE_HOST = os.environ.get("WARP_SOCKS_BRIDGE_HOST", "172.17.0.1")
BRIDGE_PORT = int(os.environ.get("WARP_SOCKS_BRIDGE_PORT", "11025")) BRIDGE_PORT = int(os.environ.get("WARP_SOCKS_BRIDGE_PORT", "11025"))
SOCKS_OUTBOUND_TAG = "warp-socks" 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) LOG_BUFFER = deque(maxlen=250)
@@ -637,6 +651,316 @@ def client_presets(socks_port: int):
} }
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): def read_json_body(handler, max_bytes=16384):
try: try:
length = int(handler.headers.get("Content-Length", "0")) length = int(handler.headers.get("Content-Length", "0"))
@@ -661,7 +985,7 @@ INDEX_HTML = r"""<!doctype html>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 24px; background:#0b1020; color:#e7e9ee; } 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; } h2,h3 { margin: 0 0 10px 0; }
.row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; } .row { display:flex; gap:10px; flex-wrap:wrap; align-items:center; }
input, textarea { padding:8px 10px; border-radius:8px; border:1px solid #2a355a; background:#0b1020; color:#e7e9ee; min-width: 220px; } 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; } 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 { padding:10px 14px; border-radius:10px; border:1px solid #2a355a; background:#121a34; color:#e7e9ee; cursor:pointer; }
button:hover { background:#172145; } button:hover { background:#172145; }
@@ -730,6 +1054,32 @@ INDEX_HTML = r"""<!doctype html>
<div id="proxyMsg" class="msg muted"></div> <div id="proxyMsg" class="msg muted"></div>
</div> </div>
<div class="card">
<h3>WARP Relay (beta)</h3>
<div class="kv">
<div class="k">Firewall</div><div id="relayFirewall">-</div>
<div class="k">Active</div><div id="relayEnabled">-</div>
<div class="k">Endpoint IP</div><div id="relayEndpointIp">-</div>
<div class="k">Rules</div><div id="relayRuleCount">-</div>
</div>
<div class="row" style="margin-top:12px">
<input id="relayEndpoint" placeholder="engage.cloudflareclient.com or IPv4"/>
<input id="relaySourceIp" placeholder="public IPv4 (auto if empty)"/>
<select id="relayMode">
<option value="single">single UDP port</option>
<option value="multiport">Cloudflare WARP multiport</option>
</select>
<input id="relayPort" type="number" min="1" max="65535" placeholder="relay port"/>
<input id="relayTargetPort" type="number" min="1" max="65535" placeholder="target port"/>
</div>
<div class="row" style="margin-top:12px">
<button id="btnRelayApply" class="secondary">Apply relay</button>
<button id="btnRelayRemove" class="danger">Remove relay rules</button>
</div>
<div id="relayMsg" class="msg muted"></div>
<pre id="relayRules" style="margin-top:10px"></pre>
</div>
<div class="card"> <div class="card">
<h3>3x-ui / Xray</h3> <h3>3x-ui / Xray</h3>
<p class="muted">Adds outbound tag <code>warp-socks</code> → 127.0.0.1:PORT and routing rule <code>geosite:google</code>. Backs up config, restarts x-ui.</p> <p class="muted">Adds outbound tag <code>warp-socks</code> → 127.0.0.1:PORT and routing rule <code>geosite:google</code>. Backs up config, restarts x-ui.</p>
@@ -840,6 +1190,22 @@ async function refreshPresets() {
el('presetsJson').value = JSON.stringify(p, null, 2); el('presetsJson').value = JSON.stringify(p, null, 2);
} catch (e) { el('presetsJson').value = 'Failed to load presets'; } } catch (e) { el('presetsJson').value = 'Failed to load presets'; }
} }
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', 'Failed to load relay status: ' + (e.message || e), false); }
}
async function refreshLogs() { async function refreshLogs() {
try { try {
const data = await api('/logs'); const data = await api('/logs');
@@ -856,7 +1222,7 @@ async function refreshLogs() {
} catch (e) { el('logMeta').textContent = 'failed to load logs'; } } catch (e) { el('logMeta').textContent = 'failed to load logs'; }
} }
async function refreshAll() { async function refreshAll() {
await Promise.all([refreshStatus(), refreshRegistration(), refreshProxy(), refreshPresets(), refreshLogs(), refreshAmneziaClients()]); await Promise.all([refreshStatus(), refreshRegistration(), refreshProxy(), refreshPresets(), refreshRelay(), refreshLogs(), refreshAmneziaClients()]);
} }
async function doAction(path) { async function doAction(path) {
badge('Working...', null); badge('Working...', null);
@@ -910,6 +1276,32 @@ el('btnSetPort').onclick = () => {
setPort(p); setPort(p);
}; };
el('btnPort40000').onclick = () => { el('proxyPortInput').value = 40000; setPort(40000); }; el('btnPort40000').onclick = () => { el('proxyPortInput').value = 40000; setPort(40000); };
el('btnRelayApply').onclick = async () => {
if (!confirm('Apply WARP Relay firewall rules on this server?')) 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', 'Applying relay rules...', null);
try {
const r = await apiPost('/relay-apply', payload);
const st = r.state || {};
setMsg('relayMsg', 'OK: ' + 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('Remove only WARP Web UI relay rules?')) return;
setMsg('relayMsg', 'Removing relay rules...', null);
try {
await apiPost('/relay-remove', {});
setMsg('relayMsg', 'Relay rules removed', true);
await refreshRelay();
} catch (e) { setMsg('relayMsg', String(e.message || e), false); }
};
el('btnXui').onclick = async () => { el('btnXui').onclick = async () => {
setMsg('xuiMsg', 'Applying x-ui preset...', null); setMsg('xuiMsg', 'Applying x-ui preset...', null);
try { try {
@@ -1037,6 +1429,8 @@ class Handler(BaseHTTPRequestHandler):
return self._json(200, warp_registration()) return self._json(200, warp_registration())
if self.path == "/proxy": if self.path == "/proxy":
return self._json(200, get_proxy_port()) return self._json(200, get_proxy_port())
if self.path == "/relay":
return self._json(200, relay_rules_status())
if self.path == "/amnezia-clients": if self.path == "/amnezia-clients":
clients, err = list_amnezia_clients() clients, err = list_amnezia_clients()
if clients is None: if clients is None:
@@ -1107,6 +1501,14 @@ class Handler(BaseHTTPRequestHandler):
code, payload = run_script(UNINSTALL_SCRIPT) code, payload = run_script(UNINSTALL_SCRIPT)
return self._json(code, payload) 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 == "/xui-preset": if self.path == "/xui-preset":
port = get_proxy_port().get("port") or 1024 port = get_proxy_port().get("port") or 1024
code, payload = apply_xui_preset(int(port)) code, payload = apply_xui_preset(int(port))