diff --git a/README.md b/README.md index b545772..5c7d5be 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ ssh root@SERVER 'chmod +x /opt/pcatelegram_web/install.sh /opt/pcatelegram_web/i | `PCATELEGRAM_WEB_ADMIN_PORT` | `1984` | port web-admin | | `PCATELEGRAM_WEB_ADMIN_USER` | `admin` | Basic Auth login | | `PCATELEGRAM_WEB_ADMIN_PASSWORD` | `admin` | web-admin password | +| `PCATELEGRAM_WEB_WARP_CONFIG` | `/opt/pcatelegram_web/warp.json` | WARP / WARP+ settings | ## Поддержка @@ -86,6 +87,22 @@ PCATELEGRAM_WEB_ADMIN_PASSWORD='strong-password' bash bootstrap.sh Без HTTPS Basic Auth гонит пароль открытым текстом. Для постоянного доступа лучше reverse proxy с TLS, но порт `1984` открыт по умолчанию по запросу проекта. +## WARP / WARP+ + +В web-admin Settings есть блок `WARP / WARP+`: + +- `Off` — WARP выключен. +- `WARP` — обычный Cloudflare WARP. +- `WARP+` — WARP+ с license key. +- `All clients` — применяет WARP на весь proxy-трафик через `warp-cli`, если Cloudflare WARP установлен на сервере. +- `One client` — сохраняет WARP/WARP+ профиль и key для выбранного клиента в `/opt/pcatelegram_web/warp.json`. + +WARP+ key не отдается в API целиком: web показывает только маску. Файл `warp.json` хранится с правами `0600` и входит в backup. + +Для реального global WARP на сервере нужен установленный `cloudflare-warp` и доступная команда `warp-cli`. По документации Cloudflare: регистрация `warp-cli registration new`, WARP+ key `warp-cli registration license `, подключение `warp-cli connect`. + +Per-client runtime routing в текущем telemt не включается автоматически: публичные параметры telemt дают users/limits/quotas/ad tags, но не документируют привязку upstream к конкретному user. Для настоящего WARP только одному клиенту нужен отдельный telemt route/service или upstream-схема. + ## Проверки ```bash diff --git a/admin-web/server.py b/admin-web/server.py index 92ceacb..ad7a566 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -18,6 +18,7 @@ import os import re import secrets import shlex +import shutil import socket import subprocess import time @@ -46,6 +47,7 @@ USER_LOCK_FILE = Path(os.getenv("PCATELEGRAM_WEB_USER_LOCK", "/run/pcatelegram_w SHARED_443_CONFIG = Path(os.getenv("PCATELEGRAM_WEB_SHARED_443", "/opt/pcatelegram_web/shared-443.json")) BACKUP_SCHEDULE_FILE = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_SCHEDULE", "/opt/pcatelegram_web/backup_schedule.json")) BACKUP_RESTORE_LOG = Path(os.getenv("PCATELEGRAM_WEB_BACKUP_RESTORE_LOG", "/var/log/pcatelegram_web-restore.log")) +WARP_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_WARP_CONFIG", "/opt/pcatelegram_web/warp.json")) HOST = os.getenv("PCATELEGRAM_WEB_ADMIN_HOST", "0.0.0.0") PORT = int(os.getenv("PCATELEGRAM_WEB_ADMIN_PORT", "1984")) @@ -473,6 +475,176 @@ def read_user_max_unique_ips() -> dict[str, int]: return read_toml_int_table("access.user_max_unique_ips") +def mask_secret(value: str) -> str: + clean = str(value or "").strip() + if not clean: + return "" + if len(clean) <= 8: + return "••••" + return f"{clean[:4]}••••{clean[-4:]}" + + +def read_warp_config() -> dict[str, Any]: + raw = load_json(WARP_CONFIG_FILE, {}) or {} + if not isinstance(raw, dict): + raw = {} + users = raw.get("users") if isinstance(raw.get("users"), dict) else {} + clean_users: dict[str, dict[str, str]] = {} + for name, item in users.items(): + name_s = str(name or "").strip() + if not USER_RE.match(name_s) or not isinstance(item, dict): + continue + mode = str(item.get("mode") or "off").strip().lower() + if mode not in {"off", "warp", "warp_plus"}: + mode = "off" + clean_users[name_s] = { + "mode": mode, + "license_key": str(item.get("license_key") or "").strip(), + "updated_at": str(item.get("updated_at") or ""), + } + mode = str(raw.get("mode") or "off").strip().lower() + if mode not in {"off", "warp", "warp_plus"}: + mode = "off" + scope = str(raw.get("scope") or "all").strip().lower() + if scope not in {"all", "user"}: + scope = "all" + user = str(raw.get("user") or "").strip() + if user and not USER_RE.match(user): + user = "" + return { + "version": 1, + "enabled": bool(raw.get("enabled")) and mode != "off", + "mode": mode, + "scope": scope, + "user": user, + "license_key": str(raw.get("license_key") or "").strip(), + "users": clean_users, + "updated_at": str(raw.get("updated_at") or ""), + } + + +def write_warp_config(config: dict[str, Any]) -> None: + config = dict(config) + config["version"] = 1 + config["updated_at"] = utc_now() + save_json(WARP_CONFIG_FILE, config, mode=0o600) + + +def warp_runtime_status() -> dict[str, Any]: + warp_cli = shutil.which("warp-cli") + payload: dict[str, Any] = { + "installed": bool(warp_cli), + "command": warp_cli or "", + "status": "not_installed", + "account": "", + "mode": "", + "last_error": "", + } + if not warp_cli: + return payload + code, out, err = run([warp_cli, "status"], timeout=8) + payload["status"] = (out or err).strip() + if code != 0: + payload["last_error"] = err.strip() or out.strip() + code, out, err = run([warp_cli, "registration", "show"], timeout=8) + if code == 0: + payload["account"] = out.strip() + else: + payload["account"] = "" + code, out, _ = run([warp_cli, "mode"], timeout=8) + if code == 0: + payload["mode"] = out.strip() + return payload + + +def public_warp_config() -> dict[str, Any]: + cfg = read_warp_config() + users_public: dict[str, dict[str, str]] = {} + for name, item in cfg.get("users", {}).items(): + users_public[name] = { + "mode": item.get("mode", "off"), + "license_mask": mask_secret(item.get("license_key", "")), + "updated_at": item.get("updated_at", ""), + } + return { + "enabled": cfg["enabled"], + "mode": cfg["mode"], + "scope": cfg["scope"], + "user": cfg["user"], + "license_mask": mask_secret(cfg.get("license_key", "")), + "users": users_public, + "updated_at": cfg.get("updated_at", ""), + "runtime": warp_runtime_status(), + "per_user_runtime_supported": False, + "per_user_note": "telemt has no documented per-user upstream routing; per-user WARP settings are stored as client metadata.", + } + + +def user_warp_payload(name: str) -> dict[str, Any]: + cfg = read_warp_config() + item = cfg.get("users", {}).get(name, {}) + inherited = cfg["enabled"] and cfg["scope"] == "all" + selected = cfg["enabled"] and cfg["scope"] == "user" and cfg.get("user") == name + mode = "off" + source = "none" + if inherited: + mode = cfg["mode"] + source = "all" + elif selected: + mode = item.get("mode") or cfg["mode"] + source = "user" + elif item: + mode = item.get("mode", "off") + source = "stored" + return { + "mode": mode, + "source": source, + "enabled": mode != "off" and source in {"all", "user"}, + "license_mask": mask_secret(item.get("license_key", "") if source != "all" else cfg.get("license_key", "")), + } + + +def apply_warp_runtime(cfg: dict[str, Any]) -> dict[str, Any]: + warp_cli = shutil.which("warp-cli") + result: dict[str, Any] = {"applied": False, "commands": [], "warnings": []} + if not warp_cli: + result["warnings"].append("warp-cli not installed") + return result + if cfg["scope"] == "user": + result["warnings"].append("per-user WARP route saved only; telemt per-user upstream routing is not documented") + return result + + def call(args: list[str], timeout: int = 20) -> tuple[int, str, str]: + code, out, err = run([warp_cli, *args], timeout=timeout) + shown_args = list(args) + if len(shown_args) >= 3 and shown_args[0:2] == ["registration", "license"]: + shown_args[2] = mask_secret(shown_args[2]) + result["commands"].append({"cmd": "warp-cli " + " ".join(shown_args), "exit_code": code}) + return code, out, err + + if not cfg["enabled"] or cfg["mode"] == "off": + call(["disconnect"], timeout=15) + result["applied"] = True + return result + + run(["systemctl", "enable", "--now", "warp-svc"], timeout=20) + code, _, _ = call(["registration", "show"], timeout=10) + if code != 0: + call(["registration", "new"], timeout=30) + if cfg["mode"] == "warp_plus": + license_key = str(cfg.get("license_key") or "").strip() + if license_key: + call(["registration", "license", license_key], timeout=30) + else: + result["warnings"].append("WARP+ selected without license key") + call(["mode", "warp+doh"], timeout=15) + code, _, err = call(["connect"], timeout=30) + result["applied"] = code == 0 + if code != 0 and err: + result["warnings"].append(err.strip()) + return result + + def read_disabled_users() -> dict[str, str]: raw = load_json(DISABLED_USERS_FILE, {}) or {} if not isinstance(raw, dict): @@ -1469,6 +1641,7 @@ def user_payload( "main": name == "main", "enabled": bool(enabled), "max_unique_ips": _int_value(max_unique_ips), + "warp": user_warp_payload(name), } if traffic_snapshot: item["traffic"] = { @@ -1513,6 +1686,7 @@ def overview_payload() -> dict[str, Any]: "runtime_summary": summary, "backups": list_backups(), "backup_schedule": backup_schedule_status(), + "warp": public_warp_config(), } @@ -1631,6 +1805,8 @@ class AdminHandler(BaseHTTPRequestHandler): path = parsed.path if path == "/api/overview": self.send_json({"ok": True, "data": overview_payload()}) + elif path == "/api/warp": + self.send_json({"ok": True, "data": public_warp_config()}) elif path == "/api/users": users = read_user_records() latest = latest_user_stats() @@ -1897,6 +2073,55 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_error_json(500, f"failed to save credentials: {exc}") return self.send_json({"ok": True, "data": {"user": username, "changed": username != current_user or new_password != current_expected}}) + elif path == "/api/warp": + current = read_warp_config() + mode = str(body.get("mode") or "off").strip().lower() + if mode not in {"off", "warp", "warp_plus"}: + self.send_error_json(400, "invalid WARP mode") + return + scope = str(body.get("scope") or "all").strip().lower() + if scope not in {"all", "user"}: + self.send_error_json(400, "invalid WARP scope") + return + user = str(body.get("user") or "").strip() + if scope == "user": + records = read_user_records() + if not USER_RE.match(user) or user not in records: + self.send_error_json(400, "invalid WARP user") + return + else: + user = "" + license_key = str(body.get("license_key") or "").strip() + if not license_key: + if scope == "user" and user: + license_key = str(current.get("users", {}).get(user, {}).get("license_key", "")) + else: + license_key = str(current.get("license_key", "")) + enabled = mode != "off" + next_cfg = dict(current) + next_cfg.update({ + "enabled": enabled, + "mode": mode, + "scope": scope, + "user": user, + }) + if scope == "all": + next_cfg["license_key"] = license_key + else: + users_cfg = dict(next_cfg.get("users") or {}) + users_cfg[user] = { + "mode": mode, + "license_key": license_key, + "updated_at": utc_now(), + } + next_cfg["users"] = users_cfg + try: + write_warp_config(next_cfg) + apply_result = apply_warp_runtime(next_cfg) + except Exception as exc: + self.send_error_json(500, f"failed to save WARP config: {exc}") + return + self.send_json({"ok": True, "data": {"config": public_warp_config(), "apply": apply_result}}) elif path == "/api/auth/logout": self.handle_logout() elif path.startswith("/api/services/") and path.endswith("/restart"): @@ -1933,9 +2158,18 @@ class AdminHandler(BaseHTTPRequestHandler): disabled.pop(name, None) limits = read_user_max_unique_ips() limits.pop(name, None) + warp_cfg = read_warp_config() + warp_users = dict(warp_cfg.get("users") or {}) + warp_users.pop(name, None) + warp_cfg["users"] = warp_users + if warp_cfg.get("user") == name: + warp_cfg["enabled"] = False + warp_cfg["mode"] = "off" + warp_cfg["user"] = "" write_telemt_users(active) write_disabled_users(disabled) write_user_max_unique_ips(limits) + write_warp_config(warp_cfg) except Exception as exc: self.send_error_json(500, f"failed to save config: {exc}") return diff --git a/admin-web/static/app.js b/admin-web/static/app.js index c1127b5..6edef13 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -106,6 +106,22 @@ const i18n = { authNewPassword: "New password", authSave: "Save login", authSaved: "Login updated", + warpEyebrow: "Routing", + warpTitle: "WARP / WARP+", + warpMode: "Mode", + warpOff: "Off", + warpScope: "Scope", + warpAllClients: "All clients", + warpOneClient: "One client", + warpClient: "Client", + warpLicense: "WARP+ key", + warpSave: "Save WARP", + warpSaved: "WARP settings saved", + warpInstalled: "warp-cli installed", + warpNotInstalled: "warp-cli not installed", + warpPerUserNote: "One-client WARP profile is stored here. Runtime per-client routing needs a dedicated telemt route; global WARP applies to all clients.", + warpAllNote: "Global WARP applies to all proxy traffic when warp-cli is installed and connected.", + savedKey: "saved key", dashboard: "Dashboard", noKeys: "No keys yet", noBackups: "No backups yet", @@ -334,6 +350,22 @@ const i18n = { authNewPassword: "Новый пароль", authSave: "Сохранить вход", authSaved: "Данные входа обновлены", + warpEyebrow: "Маршрутизация", + warpTitle: "WARP / WARP+", + warpMode: "Режим", + warpOff: "Выкл", + warpScope: "Область", + warpAllClients: "Все клиенты", + warpOneClient: "Один клиент", + warpClient: "Клиент", + warpLicense: "Ключ WARP+", + warpSave: "Сохранить WARP", + warpSaved: "Настройки WARP сохранены", + warpInstalled: "warp-cli установлен", + warpNotInstalled: "warp-cli не установлен", + warpPerUserNote: "Профиль WARP для одного клиента сохраняется здесь. Runtime-маршрут на одного клиента требует отдельный маршрут telemt; global WARP действует на всех клиентов.", + warpAllNote: "Global WARP применится ко всему proxy-трафику, если warp-cli установлен и подключён.", + savedKey: "ключ сохранён", dashboard: "Обзор", noKeys: "Ключей пока нет", noBackups: "Бекапов пока нет", @@ -477,6 +509,7 @@ const state = { userTraffic: null, userTrafficLoading: false, backupSchedule: null, + warp: null, qrLink: "", pendingUsers: new Set(), refreshingAll: false, @@ -605,6 +638,7 @@ function applyI18n() { updateTrafficControls(); updateUserTrafficControls(); renderBackupSchedule(); + renderWarpSettings(); updatePageTitle(); updateAutoRefreshToggle(); } @@ -1218,6 +1252,8 @@ function renderUsers() { const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--"; const activeIps = Number(traffic.active_unique_ips) || 0; const maxUniqueIps = Number.isFinite(Number(user.max_unique_ips)) ? Math.max(0, Number(user.max_unique_ips)) : 0; + const warp = user.warp || {}; + const warpLabel = warp.mode === "warp_plus" ? "WARP+" : (warp.mode === "warp" ? "WARP" : ""); return `
@@ -1232,6 +1268,7 @@ function renderUsers() { ${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}
+ ${warpLabel ? `${escapeHtml(warpLabel)}` : ""}
${escapeHtml(t("tableSecret"))} @@ -1272,6 +1309,30 @@ function renderUsers() { `; }).join(""); } +function renderWarpSettings() { + const cfg = state.warp || state.overview?.warp || {}; + const runtime = cfg.runtime || {}; + const modeEl = $("#warpMode"); + const scopeEl = $("#warpScope"); + const userEl = $("#warpUserSelect"); + if (!modeEl || !scopeEl || !userEl) return; + modeEl.value = cfg.mode || "off"; + scopeEl.value = cfg.scope || "all"; + userEl.innerHTML = state.users.map((user) => ``).join(""); + userEl.value = cfg.user && state.users.some((user) => user.name === cfg.user) ? cfg.user : (state.users[0]?.name || ""); + $("#warpUserRow").hidden = scopeEl.value !== "user"; + const mask = scopeEl.value === "user" + ? (cfg.users?.[userEl.value]?.license_mask || "") + : (cfg.license_mask || ""); + $("#warpLicenseKey").placeholder = mask ? `${t("savedKey")}: ${mask}` : "optional"; + $("#warpStatus").textContent = runtime.installed ? t("warpInstalled") : t("warpNotInstalled"); + $("#warpStatus").classList.toggle("ok", Boolean(runtime.installed)); + const statusLine = runtime.status ? ` ${runtime.status}` : ""; + $("#warpRuntimeNote").textContent = scopeEl.value === "user" + ? `${t("warpPerUserNote")}${statusLine ? ` ${statusLine}` : ""}` + : `${t("warpAllNote")}${statusLine ? ` ${statusLine}` : ""}`; +} + function renderBackups(backups) { const box = $("#backupsList"); renderBackupSchedule(); @@ -1346,6 +1407,7 @@ async function refreshAll() { try { state.overview = await api("/api/overview"); state.backupSchedule = state.overview.backup_schedule || state.backupSchedule; + state.warp = state.overview.warp || state.warp; updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); ensureUserTrafficSelection(); @@ -1365,6 +1427,7 @@ async function refreshAll() { } renderOverview(); renderUsers(); + renderWarpSettings(); if (state.page === "traffic") { await refreshStats(); } else if (state.page === "keys") { @@ -1522,6 +1585,36 @@ async function setUserMaxUniqueIps(name, value) { } } +async function saveWarpSettings(eventObj) { + eventObj.preventDefault(); + const form = eventObj.currentTarget; + const controls = Array.from(form.querySelectorAll("input, select, button")); + controls.forEach((control) => { control.disabled = true; }); + try { + const payload = { + mode: $("#warpMode").value, + scope: $("#warpScope").value, + user: $("#warpUserSelect").value, + license_key: $("#warpLicenseKey").value.trim(), + }; + const data = await api("/api/warp", { + method: "POST", + body: JSON.stringify(payload), + }); + state.warp = data.config; + $("#warpLicenseKey").value = ""; + renderWarpSettings(); + const warning = data.apply?.warnings?.[0] || ""; + toast(warning || t("warpSaved")); + addEvent(t("warpSaved"), payload.scope === "user" ? payload.user : payload.mode); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + controls.forEach((control) => { control.disabled = false; }); + } +} + async function createBackup() { const btn = $("#createBackupBtn"); btn.disabled = true; @@ -1760,6 +1853,9 @@ $("#loadLogsBtn").addEventListener("click", loadLogs); $("#repairStatsBtn").addEventListener("click", repairStats); $("#collectStatsBtn").addEventListener("click", collectStats); $("#authSettingsForm").addEventListener("submit", updateAuthSettings); +$("#warpSettingsForm").addEventListener("submit", saveWarpSettings); +$("#warpScope").addEventListener("change", renderWarpSettings); +$("#warpUserSelect").addEventListener("change", renderWarpSettings); window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false)); setPage((location.hash || "#dashboard").slice(1), false); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 0b51255..cf34138 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -360,6 +360,43 @@ +
+
+
+

Routing

+

WARP / WARP+

+
+ -- +
+
+ + + + + + +
+
+
@@ -403,6 +440,6 @@
- + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 0acb60d..33fbe55 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -916,6 +916,23 @@ h2 { min-width: 0; } +.warp-badge { + justify-self: start; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--panel-strong); + color: var(--muted); + padding: 3px 8px; + font-size: 11px; + font-weight: 900; +} + +.warp-badge.active { + border-color: color-mix(in srgb, var(--violet) 44%, var(--line)); + background: color-mix(in srgb, var(--violet) 14%, var(--panel)); + color: var(--violet); +} + .key-card-user { grid-area: user; } .key-card-secret { grid-area: secret; } .key-card-links { grid-area: links; } diff --git a/lib/backup.sh b/lib/backup.sh index 437191c..bcb8240 100755 --- a/lib/backup.sh +++ b/lib/backup.sh @@ -44,6 +44,9 @@ create_backup() { if [ -f "$PCATELEGRAM_WEB_DIR/backup_schedule.json" ]; then cp "$PCATELEGRAM_WEB_DIR/backup_schedule.json" "$tmp_dir/backup_schedule.json" 2>/dev/null fi + if [ -f "$PCATELEGRAM_WEB_DIR/warp.json" ]; then + cp "$PCATELEGRAM_WEB_DIR/warp.json" "$tmp_dir/warp.json" 2>/dev/null + fi # Language marker (i18n) if [ -f "$PCATELEGRAM_WEB_DIR/.language" ]; then @@ -247,6 +250,9 @@ restore_backup() { if [ ! -f "$backup_dir/disabled_users.json" ] && [ -f "$tmp_dir/opt/pcatelegram_web/disabled_users.json" ]; then cp "$tmp_dir/opt/pcatelegram_web/disabled_users.json" "$backup_dir/disabled_users.json" 2>/dev/null || true fi + if [ ! -f "$backup_dir/warp.json" ] && [ -f "$tmp_dir/opt/pcatelegram_web/warp.json" ]; then + cp "$tmp_dir/opt/pcatelegram_web/warp.json" "$backup_dir/warp.json" 2>/dev/null || true + fi # Проверяем метаданные if [ -f "$backup_dir/metadata.json" ]; then @@ -303,6 +309,11 @@ restore_backup() { esac fi fi + if [ -f "$backup_dir/warp.json" ]; then + mkdir -p "$PCATELEGRAM_WEB_DIR" + cp "$backup_dir/warp.json" "$PCATELEGRAM_WEB_DIR/warp.json" 2>/dev/null + chmod 600 "$PCATELEGRAM_WEB_DIR/warp.json" 2>/dev/null || true + fi # Восстанавливаем language marker (i18n) if [ -f "$backup_dir/.language" ]; then