commit 4f4e85f8107edaede140984ead712424a289def8 Author: Андрей Бобырев Date: Tue May 26 22:50:57 2026 +0300 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 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."