From 4f4e85f8107edaede140984ead712424a289def8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Tue, 26 May 2026 22:50:57 +0300 Subject: [PATCH] feat: publish WARP Web UI with install scripts and docs Sanitized panel from production: env-based auth, interactive install prompts for SOCKS/Web UI ports, MIT license, EN/RU README. Co-authored-by: Cursor --- .env.example | 22 + .gitignore | 7 + LICENSE | 21 + README.md | 108 ++++ README.ru.md | 83 +++ app.py | 1152 ++++++++++++++++++++++++++++++++++ install.sh | 114 ++++ scripts/warp-install-cf.sh | 24 + scripts/warp-uninstall-cf.sh | 10 + systemd/warp-webui.service | 20 + uninstall.sh | 59 ++ 11 files changed, 1620 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.ru.md create mode 100755 app.py create mode 100755 install.sh create mode 100755 scripts/warp-install-cf.sh create mode 100755 scripts/warp-uninstall-cf.sh create mode 100644 systemd/warp-webui.service create mode 100755 uninstall.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d33a448 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# Copy to /etc/default/warp-webui (install.sh creates this automatically) + +WARP_WEBUI_USER=warpadmin +WARP_WEBUI_PASS=change-me-to-a-strong-password +WARP_WEBUI_HOST=0.0.0.0 +WARP_WEBUI_PORT=3030 + +# SOCKS port for warp-cli proxy mode (install script asks for this) +WARP_PROXY_PORT=40000 + +# Shown in client preset JSON (your server's public IP or hostname) +WARP_PUBLIC_HOST= + +# Optional paths (defaults match install.sh layout) +# WARP_INSTALL_SCRIPT=/opt/warp-webui/scripts/warp-install-cf.sh +# WARP_UNINSTALL_SCRIPT=/opt/warp-webui/scripts/warp-uninstall-cf.sh +# XUI_CONFIG=/usr/local/x-ui/bin/config.json +# AMNEZIA_XRAY_CONTAINER=amnezia-xray +# AMNEZIA_XRAY_CONFIG=/opt/amnezia/xray/server.json +# WARP_CLIENT_ALIASES=/etc/warp-webui/client-aliases.json +# WARP_SOCKS_BRIDGE_HOST=172.17.0.1 +# WARP_SOCKS_BRIDGE_PORT=11025 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84e6b3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +*.bak* +_src/ +.env +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a45e2f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Andrey + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0361a93 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# WARP Web UI + +Browser-based control panel for **Cloudflare WARP** on Linux: connect/disconnect, WARP+ license, SOCKS proxy port, and optional presets for **3x-ui** and **Amnezia Xray**. + +## Features + +- **WARP control**: Connect, disconnect, restart `warp-svc`, view status and logs +- **Account**: Registration info, apply WARP+ license key +- **Install / uninstall** `cloudflare-warp` from the UI (Debian/Ubuntu apt repo) +- **SOCKS proxy**: Set `warp-cli` proxy port (e.g. `40000` or `1024`) +- **3x-ui preset**: Add `warp-socks` outbound → `127.0.0.1:PORT` and `geosite:google` routing rule +- **Amnezia preset**: Docker bridge `172.17.0.1:11025` → host SOCKS, per-client WARP routing with friendly names + +## Requirements + +- Linux (Debian/Ubuntu recommended) +- `python3` (stdlib only — no pip packages) +- `systemd` +- Optional: `cloudflare-warp` package (can be installed via UI or `scripts/warp-install-cf.sh`) +- Optional: `docker`, `socat`, `x-ui` / Amnezia for integration presets + +## Quick start + +### One-line install + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/WARP-Web-UI/main/install.sh | sudo bash +``` + +The installer **asks interactively**: + +| Prompt | Default | Notes | +|--------|---------|--------| +| SOCKS proxy port | `40000` | Also `1024` is common for official `cloudflare-warp` | +| Web UI port | `3030` | Open in firewall if needed | +| Admin username | `warpadmin` | HTTP Basic Auth | +| Admin password | *(required, min 8 chars)* | Stored in `/etc/default/warp-webui` (`chmod 600`) | + +### Clone and install + +```bash +git clone https://github.com/andrey271192/WARP-Web-UI.git +cd WARP-Web-UI +sudo bash install.sh +``` + +Open `http://YOUR_SERVER:3030/` (use the port you chose). Log in with the credentials you set. + +If WARP is not installed yet, click **Install WARP** in the UI (or run `scripts/warp-install-cf.sh` with `WARP_PROXY_PORT` set). + +### Uninstall + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/WARP-Web-UI/main/uninstall.sh | sudo bash +``` + +Or from a clone: `sudo bash uninstall.sh` — stops the service, optionally removes files, config, and the `cloudflare-warp` package. + +## Repository layout + +``` +app.py # Web UI + API (Python http.server) +scripts/warp-install-cf.sh # Install cloudflare-warp from Cloudflare apt repo +scripts/warp-uninstall-cf.sh # Remove cloudflare-warp package +systemd/warp-webui.service # systemd unit template +install.sh / uninstall.sh # One-command setup / teardown +.env.example # Environment variable reference +``` + +After install, files live under `/opt/warp-webui/`, config in `/etc/default/warp-webui`. + +## Configuration + +See [`.env.example`](.env.example). Main variables: + +- `WARP_WEBUI_USER`, `WARP_WEBUI_PASS` — Basic Auth +- `WARP_WEBUI_PORT` — HTTP port (default `3030`) +- `WARP_PROXY_PORT` — SOCKS port used at install and for `warp-install` from UI +- `WARP_PUBLIC_HOST` — Public IP/hostname for client preset hints + +Client display names for Amnezia: `/etc/warp-webui/client-aliases.json` + +## Security notes + +- **HTTP Basic Auth only** — credentials are sent on every request. Prefer **HTTPS** (reverse proxy: nginx/Caddy + TLS) for production. +- **Firewall**: Expose only the Web UI port to trusted IPs (`ufw allow from TRUSTED to any port 3030`). +- **Root service**: The panel runs as root to manage `warp-cli`, systemd, and Docker. Do not expose it to the public internet without protection. +- **Secrets**: Never commit `/etc/default/warp-webui`. Rotate the admin password after install. +- WARP+ license keys are entered in the UI and passed to `warp-cli` — they are not stored in this repo. + +## API (authenticated) + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/` | HTML UI | +| GET | `/status`, `/registration`, `/proxy`, `/logs` | Status | +| POST | `/connect`, `/disconnect`, `/restart` | WARP control | +| POST | `/warp-install`, `/warp-uninstall` | Package install/remove | +| POST | `/proxy-port`, `/license` | SOCKS port, WARP+ key | +| POST | `/xui-preset`, `/amnezia-preset`, `/amnezia-routing` | Integration presets | + +## License + +MIT — see [LICENSE](LICENSE). + +## Russian documentation + +See [README.ru.md](README.ru.md). diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..79abcd6 --- /dev/null +++ b/README.ru.md @@ -0,0 +1,83 @@ +# WARP Web UI + +Веб-панель для управления **Cloudflare WARP** на Linux: подключение/отключение, ключ WARP+, порт SOCKS-прокси и пресеты для **3x-ui** и **Amnezia Xray**. + +## Возможности + +- **WARP**: Connect / Disconnect, перезапуск `warp-svc`, статус и логи +- **Аккаунт**: тип регистрации (Free / WARP+), применение лицензионного ключа +- **Установка и удаление** пакета `cloudflare-warp` из браузера +- **SOCKS**: смена порта `warp-cli proxy` (например `40000` или `1024`) +- **3x-ui**: outbound `warp-socks` → `127.0.0.1:ПОРТ`, маршрут `geosite:google` +- **Amnezia**: мост Docker `172.17.0.1:11025` → SOCKS на хосте, WARP только для выбранных клиентов, понятные имена + +## Требования + +- Linux (рекомендуется Debian/Ubuntu) +- `python3` (только стандартная библиотека) +- `systemd` +- По желанию: `cloudflare-warp` (ставится из UI или `scripts/warp-install-cf.sh`) +- Для пресетов: `docker`, `socat`, `x-ui` / Amnezia + +## Быстрый старт + +### Установка одной командой + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/WARP-Web-UI/main/install.sh | sudo bash +``` + +Скрипт **спрашивает**: + +| Вопрос | По умолчанию | Пояснение | +|--------|--------------|-----------| +| Порт SOCKS | `40000` | Часто также `1024` у официального пакета | +| Порт веб-UI | `3030` | Откройте в firewall при необходимости | +| Логин админа | `warpadmin` | HTTP Basic Auth | +| Пароль админа | *(обязательно, ≥ 8 символов)* | Файл `/etc/default/warp-webui` | + +### Клонирование + +```bash +git clone https://github.com/andrey271192/WARP-Web-UI.git +cd WARP-Web-UI +sudo bash install.sh +``` + +Откройте `http://ВАШ_СЕРВЕР:3030/`. Если WARP ещё не установлен — кнопка **Install WARP** в интерфейсе. + +### Удаление + +```bash +curl -fsSL https://raw.githubusercontent.com/andrey271192/WARP-Web-UI/main/uninstall.sh | sudo bash +``` + +Или `sudo bash uninstall.sh` из клонированного репозитория. Можно удалить только панель или также пакет `cloudflare-warp` (подтверждение в конце). + +## Структура репозитория + +- `app.py` — веб-интерфейс и API +- `scripts/warp-install-cf.sh`, `warp-uninstall-cf.sh` — установка/удаление WARP +- `systemd/warp-webui.service` — шаблон unit +- `install.sh`, `uninstall.sh` — установка и снятие «в одну кнопку» + +После установки: `/opt/warp-webui/`, настройки `/etc/default/warp-webui`. + +## Настройка + +См. [`.env.example`](.env.example). Имена клиентов Amnezia: `/etc/warp-webui/client-aliases.json`. + +## Безопасность + +- Только **Basic Auth по HTTP** — для продакшена используйте **HTTPS** (nginx/Caddy). +- **Firewall**: открывайте порт панели только для доверенных IP. +- Сервис работает от **root** (нужен для `warp-cli`, systemd, Docker). Не выставляйте панель в открытый интернет без защиты. +- Не публикуйте `/etc/default/warp-webui` и смените пароль после установки. + +## English documentation + +See [README.md](README.md). + +## Лицензия + +MIT — [LICENSE](LICENSE). diff --git a/app.py b/app.py new file mode 100755 index 0000000..d1f3475 --- /dev/null +++ b/app.py @@ -0,0 +1,1152 @@ +#!/usr/bin/env python3 +import base64 +import json +import os +import re +import shutil +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" + +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 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""" + + + + + WARP Web UI + + + +

WARP Web UI

+
Basic Auth. Auto-refresh every 4s.
+ +
+
+ + + + + +
+
+ +
+

Account (WARP registration)

+
+
Account type
-
+
Account ID
-
+
Device ID
-
+
License
-
+
+
+ + +
+
+
+ +
+

WARP package

+
+ + +
+
+
+ +
+

SOCKS proxy port (warp-cli)

+
+
Current port
-
+
Endpoint
-
+
+
+ + + +
+
+
+ +
+

3x-ui / Xray

+

Adds outbound tag warp-socks → 127.0.0.1:PORT and routing rule geosite:google. Backs up config, restarts x-ui.

+ +
+
+ +
+

Amnezia Xray (Docker)

+

Мост 172.17.0.1:11025 → SOCKS на хосте. В маршрутизации Xray по-прежнему UUID/email; в UI — понятные имена (алиасы: /etc/warp-webui/client-aliases.json).

+ +
+

WARP только для выбранных клиентов

+

Отметьте клиентов по имени. Ничего не выбрано → общее правило geosite. Имя можно сохранить — переживёт перезапуск UI.

+
Loading clients...
+
+ +
+ +
+
+ +
+

Client presets (JSON)

+ + +
+ +
+

Status

+
+
Connected
-
+
Health
-
+
Account
-
+
Device
-
+
Last stderr
+
Last stdout
+
+
+ +
+

Logs (recent)

+
+

+  
+ + + +""" + + +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 == "/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 == "/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 == "/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() diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..19e8f1d --- /dev/null +++ b/install.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# WARP Web UI — one-command installer (run as root on Debian/Ubuntu) +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="${WARP_WEBUI_INSTALL_DIR:-/opt/warp-webui}" +ENV_FILE="/etc/default/warp-webui" +SERVICE_NAME="warp-webui" +UNIT_DST="/etc/systemd/system/${SERVICE_NAME}.service" +ALIASES_DIR="/etc/warp-webui" +LOG_DIR="/var/log/warp-webui" + +if [[ "${EUID:-0}" -ne 0 ]]; then + echo "Run as root: sudo bash install.sh" + exit 1 +fi + +echo "=== WARP Web UI installer ===" +echo + +prompt() { + local var_name="$1" prompt_text="$2" default_val="$3" + local input + read -rp "${prompt_text} [${default_val}]: " input + if [[ -z "${input}" ]]; then + printf -v "${var_name}" '%s' "${default_val}" + else + printf -v "${var_name}" '%s' "${input}" + fi +} + +echo "SOCKS port: local port for warp-cli proxy mode (used by x-ui / Amnezia presets)." +echo "Common choices: 40000 (warp-offline style) or 1024 (cloudflare-warp default)." +prompt WARP_PROXY_PORT "SOCKS proxy port" "40000" +if ! [[ "${WARP_PROXY_PORT}" =~ ^[0-9]+$ ]] || (( WARP_PROXY_PORT < 1 || WARP_PROXY_PORT > 65535 )); then + echo "Invalid SOCKS port: ${WARP_PROXY_PORT}" + exit 1 +fi + +prompt WARP_WEBUI_PORT "Web UI HTTP port" "3030" +if ! [[ "${WARP_WEBUI_PORT}" =~ ^[0-9]+$ ]] || (( WARP_WEBUI_PORT < 1 || WARP_WEBUI_PORT > 65535 )); then + echo "Invalid Web UI port: ${WARP_WEBUI_PORT}" + exit 1 +fi + +prompt WARP_WEBUI_USER "Web UI admin username" "warpadmin" + +while true; do + read -rsp "Web UI admin password (min 8 chars): " WARP_WEBUI_PASS + echo + if [[ "${#WARP_WEBUI_PASS}" -ge 8 ]]; then + break + fi + echo "Password too short. Use at least 8 characters." +done + +# Detect public host for client preset hints (optional) +WARP_PUBLIC_HOST="${WARP_PUBLIC_HOST:-}" +if [[ -z "${WARP_PUBLIC_HOST}" ]]; then + WARP_PUBLIC_HOST="$(curl -fsS --max-time 3 https://api.ipify.org 2>/dev/null || true)" +fi +if [[ -z "${WARP_PUBLIC_HOST}" ]]; then + WARP_PUBLIC_HOST="$(hostname -f 2>/dev/null || hostname)" +fi + +echo +echo "Installing to ${INSTALL_DIR} ..." +mkdir -p "${INSTALL_DIR}/scripts" "${ALIASES_DIR}" "${LOG_DIR}" +install -m 0755 "${REPO_DIR}/app.py" "${INSTALL_DIR}/app.py" +install -m 0755 "${REPO_DIR}/scripts/warp-install-cf.sh" "${INSTALL_DIR}/scripts/warp-install-cf.sh" +install -m 0755 "${REPO_DIR}/scripts/warp-uninstall-cf.sh" "${INSTALL_DIR}/scripts/warp-uninstall-cf.sh" + +umask 077 +cat > "${ENV_FILE}" < "${ALIASES_DIR}/client-aliases.json" + chmod 600 "${ALIASES_DIR}/client-aliases.json" +fi + +sed \ + -e "s|@INSTALL_DIR@|${INSTALL_DIR}|g" \ + -e "s|@ENV_FILE@|${ENV_FILE}|g" \ + "${REPO_DIR}/systemd/warp-webui.service" > "${UNIT_DST}" + +systemctl daemon-reload +systemctl enable "${SERVICE_NAME}.service" +systemctl restart "${SERVICE_NAME}.service" + +# Optional: open firewall for Web UI port +if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi active; then + ufw allow "${WARP_WEBUI_PORT}/tcp" comment 'warp-webui' || true +fi + +echo +echo "=== Installed ===" +echo "Web UI: http://${WARP_PUBLIC_HOST}:${WARP_WEBUI_PORT}/" +echo "Login: ${WARP_WEBUI_USER} / (password you entered)" +echo "SOCKS: 127.0.0.1:${WARP_PROXY_PORT} (after WARP is installed and proxy mode enabled)" +echo "Env: ${ENV_FILE}" +echo +echo "Next: open the Web UI and use 'Install WARP' if cloudflare-warp is not installed yet." +echo "Set proxy port in the UI or re-run install with a different WARP_PROXY_PORT in ${ENV_FILE}." diff --git a/scripts/warp-install-cf.sh b/scripts/warp-install-cf.sh new file mode 100755 index 0000000..36fbd44 --- /dev/null +++ b/scripts/warp-install-cf.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +if command -v warp-cli >/dev/null 2>&1; then + echo "cloudflare-warp already installed: $(warp-cli --version 2>/dev/null || true)" + systemctl enable --now warp-svc 2>/dev/null || true + warp-cli --accept-tos registration show 2>/dev/null || warp-cli --accept-tos registration new 2>/dev/null || true + warp-cli --accept-tos mode proxy 2>/dev/null || true + warp-cli --accept-tos proxy port "${WARP_PROXY_PORT:-1024}" 2>/dev/null || true + exit 0 +fi +apt-get update -qq +apt-get install -y -qq curl gnupg lsb-release ca-certificates +mkdir -p /usr/share/keyrings +curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | gpg --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg +echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/cloudflare-client.list +apt-get update -qq +apt-get install -y -qq cloudflare-warp +systemctl enable --now warp-svc +sleep 2 +warp-cli --accept-tos registration new || true +warp-cli --accept-tos mode proxy +warp-cli --accept-tos proxy port "${WARP_PROXY_PORT:-1024}" +echo "WARP installed." diff --git a/scripts/warp-uninstall-cf.sh b/scripts/warp-uninstall-cf.sh new file mode 100755 index 0000000..0bb12b0 --- /dev/null +++ b/scripts/warp-uninstall-cf.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +warp-cli --accept-tos disconnect 2>/dev/null || true +systemctl stop warp-svc 2>/dev/null || true +systemctl disable warp-svc 2>/dev/null || true +if dpkg -l cloudflare-warp >/dev/null 2>&1; then + apt-get remove -y -qq cloudflare-warp || apt-get purge -y -qq cloudflare-warp +fi +echo "WARP package removed (config may remain under /var/lib/cloudflare-warp)." diff --git a/systemd/warp-webui.service b/systemd/warp-webui.service new file mode 100644 index 0000000..aba663e --- /dev/null +++ b/systemd/warp-webui.service @@ -0,0 +1,20 @@ +[Unit] +Description=WARP Web UI Controller +After=network-online.target warp-svc.service +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=@ENV_FILE@ +ExecStart=/usr/bin/python3 @INSTALL_DIR@/app.py +Restart=always +RestartSec=2 +User=root +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=true +ReadWritePaths=/run /var/log /etc/default/warp-webui /etc/warp-webui /var/backups/warp-webui + +[Install] +WantedBy=multi-user.target diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..a4e97e2 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# WARP Web UI — one-command uninstaller +set -euo pipefail + +INSTALL_DIR="${WARP_WEBUI_INSTALL_DIR:-/opt/warp-webui}" +ENV_FILE="/etc/default/warp-webui" +SERVICE_NAME="warp-webui" +UNIT="/etc/systemd/system/${SERVICE_NAME}.service" +BRIDGE_UNIT="/etc/systemd/system/warp-socks-bridge.service" + +if [[ "${EUID:-0}" -ne 0 ]]; then + echo "Run as root: sudo bash uninstall.sh" + exit 1 +fi + +echo "=== WARP Web UI uninstaller ===" + +if systemctl is-active --quiet "${SERVICE_NAME}.service" 2>/dev/null; then + systemctl stop "${SERVICE_NAME}.service" +fi +systemctl disable "${SERVICE_NAME}.service" 2>/dev/null || true + +if [[ -f "${UNIT}" ]]; then + rm -f "${UNIT}" +fi + +if systemctl is-active --quiet warp-socks-bridge.service 2>/dev/null; then + systemctl stop warp-socks-bridge.service 2>/dev/null || true +fi +systemctl disable warp-socks-bridge.service 2>/dev/null || true +[[ -f "${BRIDGE_UNIT}" ]] && rm -f "${BRIDGE_UNIT}" + +systemctl daemon-reload + +read -rp "Remove application files in ${INSTALL_DIR}? [y/N]: " REMOVE_APP +if [[ "${REMOVE_APP,,}" == "y" || "${REMOVE_APP,,}" == "yes" ]]; then + rm -rf "${INSTALL_DIR}" +fi + +read -rp "Remove config ${ENV_FILE} and /etc/warp-webui/? [y/N]: " REMOVE_CFG +if [[ "${REMOVE_CFG,,}" == "y" || "${REMOVE_CFG,,}" == "yes" ]]; then + rm -f "${ENV_FILE}" + rm -rf /etc/warp-webui +fi + +read -rp "Remove cloudflare-warp package (apt remove)? [y/N]: " REMOVE_WARP +if [[ "${REMOVE_WARP,,}" == "y" || "${REMOVE_WARP,,}" == "yes" ]]; then + if [[ -x "${INSTALL_DIR}/scripts/warp-uninstall-cf.sh" ]]; then + WARP_PROXY_PORT=1024 bash "${INSTALL_DIR}/scripts/warp-uninstall-cf.sh" + elif command -v warp-cli >/dev/null 2>&1; then + warp-cli --accept-tos disconnect 2>/dev/null || true + systemctl stop warp-svc 2>/dev/null || true + apt-get remove -y -qq cloudflare-warp 2>/dev/null || apt-get purge -y -qq cloudflare-warp 2>/dev/null || true + else + echo "cloudflare-warp not found; skipped." + fi +fi + +echo "WARP Web UI uninstalled."