mirror of
https://github.com/andrey271192/PCAtelegram_web.git
synced 2026-09-20 11:55:32 +00:00
feat: add warp settings
This commit is contained in:
17
README.md
17
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 <KEY>`, подключение `warp-cli connect`.
|
||||
|
||||
Per-client runtime routing в текущем telemt не включается автоматически: публичные параметры telemt дают users/limits/quotas/ad tags, но не документируют привязку upstream к конкретному user. Для настоящего WARP только одному клиенту нужен отдельный telemt route/service или upstream-схема.
|
||||
|
||||
## Проверки
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `
|
||||
<article class="key-card ${user.enabled ? "" : "disabled-row"} ${pending ? "pending-row" : ""} ${selected ? "selected-row" : ""}" data-select-user-traffic="${escapeAttr(user.name)}" aria-selected="${selected ? "true" : "false"}">
|
||||
<div class="key-card-user">
|
||||
@@ -1232,6 +1268,7 @@ function renderUsers() {
|
||||
</label>
|
||||
<strong class="${user.enabled ? "state-on" : "state-off"}">${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}</strong>
|
||||
</div>
|
||||
${warpLabel ? `<span class="warp-badge ${warp.enabled ? "active" : ""}" title="${escapeAttr(warp.source || "")}">${escapeHtml(warpLabel)}</span>` : ""}
|
||||
</div>
|
||||
<div class="key-card-secret">
|
||||
<span class="field-label">${escapeHtml(t("tableSecret"))}</span>
|
||||
@@ -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) => `<option value="${escapeAttr(user.name)}">${escapeHtml(user.name)}</option>`).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);
|
||||
|
||||
@@ -360,6 +360,43 @@
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow" data-i18n="warpEyebrow">Routing</p>
|
||||
<h2 data-i18n="warpTitle">WARP / WARP+</h2>
|
||||
</div>
|
||||
<span id="warpStatus" class="status-pill">--</span>
|
||||
</div>
|
||||
<form id="warpSettingsForm" class="settings-form">
|
||||
<label>
|
||||
<span data-i18n="warpMode">Mode</span>
|
||||
<select name="mode" id="warpMode">
|
||||
<option value="off" data-i18n="warpOff">Off</option>
|
||||
<option value="warp">WARP</option>
|
||||
<option value="warp_plus">WARP+</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="warpScope">Scope</span>
|
||||
<select name="scope" id="warpScope">
|
||||
<option value="all" data-i18n="warpAllClients">All clients</option>
|
||||
<option value="user" data-i18n="warpOneClient">One client</option>
|
||||
</select>
|
||||
</label>
|
||||
<label id="warpUserRow">
|
||||
<span data-i18n="warpClient">Client</span>
|
||||
<select name="user" id="warpUserSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="warpLicense">WARP+ key</span>
|
||||
<input name="license_key" id="warpLicenseKey" autocomplete="off" spellcheck="false" placeholder="optional">
|
||||
</label>
|
||||
<p id="warpRuntimeNote" class="modal-note"></p>
|
||||
<button type="submit" data-i18n="warpSave">Save WARP</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
@@ -403,6 +440,6 @@
|
||||
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js?v=2.5.0-admin20" type="module"></script>
|
||||
<script src="/app.js?v=2.5.0-admin21" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user