feat: add warp settings

This commit is contained in:
Андрей Бобырев
2026-06-06 14:09:30 +03:00
parent 9121d17229
commit e534108f1e
6 changed files with 413 additions and 1 deletions

View File

@@ -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_PORT` | `1984` | port web-admin |
| `PCATELEGRAM_WEB_ADMIN_USER` | `admin` | Basic Auth login | | `PCATELEGRAM_WEB_ADMIN_USER` | `admin` | Basic Auth login |
| `PCATELEGRAM_WEB_ADMIN_PASSWORD` | `admin` | web-admin password | | `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` открыт по умолчанию по запросу проекта. Без 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 ```bash

View File

@@ -18,6 +18,7 @@ import os
import re import re
import secrets import secrets
import shlex import shlex
import shutil
import socket import socket
import subprocess import subprocess
import time 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")) 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_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")) 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") HOST = os.getenv("PCATELEGRAM_WEB_ADMIN_HOST", "0.0.0.0")
PORT = int(os.getenv("PCATELEGRAM_WEB_ADMIN_PORT", "1984")) 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") 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]: def read_disabled_users() -> dict[str, str]:
raw = load_json(DISABLED_USERS_FILE, {}) or {} raw = load_json(DISABLED_USERS_FILE, {}) or {}
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -1469,6 +1641,7 @@ def user_payload(
"main": name == "main", "main": name == "main",
"enabled": bool(enabled), "enabled": bool(enabled),
"max_unique_ips": _int_value(max_unique_ips), "max_unique_ips": _int_value(max_unique_ips),
"warp": user_warp_payload(name),
} }
if traffic_snapshot: if traffic_snapshot:
item["traffic"] = { item["traffic"] = {
@@ -1513,6 +1686,7 @@ def overview_payload() -> dict[str, Any]:
"runtime_summary": summary, "runtime_summary": summary,
"backups": list_backups(), "backups": list_backups(),
"backup_schedule": backup_schedule_status(), "backup_schedule": backup_schedule_status(),
"warp": public_warp_config(),
} }
@@ -1631,6 +1805,8 @@ class AdminHandler(BaseHTTPRequestHandler):
path = parsed.path path = parsed.path
if path == "/api/overview": if path == "/api/overview":
self.send_json({"ok": True, "data": overview_payload()}) 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": elif path == "/api/users":
users = read_user_records() users = read_user_records()
latest = latest_user_stats() latest = latest_user_stats()
@@ -1897,6 +2073,55 @@ class AdminHandler(BaseHTTPRequestHandler):
self.send_error_json(500, f"failed to save credentials: {exc}") self.send_error_json(500, f"failed to save credentials: {exc}")
return return
self.send_json({"ok": True, "data": {"user": username, "changed": username != current_user or new_password != current_expected}}) 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": elif path == "/api/auth/logout":
self.handle_logout() self.handle_logout()
elif path.startswith("/api/services/") and path.endswith("/restart"): elif path.startswith("/api/services/") and path.endswith("/restart"):
@@ -1933,9 +2158,18 @@ class AdminHandler(BaseHTTPRequestHandler):
disabled.pop(name, None) disabled.pop(name, None)
limits = read_user_max_unique_ips() limits = read_user_max_unique_ips()
limits.pop(name, None) 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_telemt_users(active)
write_disabled_users(disabled) write_disabled_users(disabled)
write_user_max_unique_ips(limits) write_user_max_unique_ips(limits)
write_warp_config(warp_cfg)
except Exception as exc: except Exception as exc:
self.send_error_json(500, f"failed to save config: {exc}") self.send_error_json(500, f"failed to save config: {exc}")
return return

View File

@@ -106,6 +106,22 @@ const i18n = {
authNewPassword: "New password", authNewPassword: "New password",
authSave: "Save login", authSave: "Save login",
authSaved: "Login updated", 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", dashboard: "Dashboard",
noKeys: "No keys yet", noKeys: "No keys yet",
noBackups: "No backups yet", noBackups: "No backups yet",
@@ -334,6 +350,22 @@ const i18n = {
authNewPassword: "Новый пароль", authNewPassword: "Новый пароль",
authSave: "Сохранить вход", authSave: "Сохранить вход",
authSaved: "Данные входа обновлены", 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: "Обзор", dashboard: "Обзор",
noKeys: "Ключей пока нет", noKeys: "Ключей пока нет",
noBackups: "Бекапов пока нет", noBackups: "Бекапов пока нет",
@@ -477,6 +509,7 @@ const state = {
userTraffic: null, userTraffic: null,
userTrafficLoading: false, userTrafficLoading: false,
backupSchedule: null, backupSchedule: null,
warp: null,
qrLink: "", qrLink: "",
pendingUsers: new Set(), pendingUsers: new Set(),
refreshingAll: false, refreshingAll: false,
@@ -605,6 +638,7 @@ function applyI18n() {
updateTrafficControls(); updateTrafficControls();
updateUserTrafficControls(); updateUserTrafficControls();
renderBackupSchedule(); renderBackupSchedule();
renderWarpSettings();
updatePageTitle(); updatePageTitle();
updateAutoRefreshToggle(); updateAutoRefreshToggle();
} }
@@ -1218,6 +1252,8 @@ function renderUsers() {
const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--"; const trafficTotal = Number(traffic.total_octets) ? fmtBytes(traffic.total_octets) : "--";
const activeIps = Number(traffic.active_unique_ips) || 0; 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 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 ` 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"}"> <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"> <div class="key-card-user">
@@ -1232,6 +1268,7 @@ function renderUsers() {
</label> </label>
<strong class="${user.enabled ? "state-on" : "state-off"}">${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}</strong> <strong class="${user.enabled ? "state-on" : "state-off"}">${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}</strong>
</div> </div>
${warpLabel ? `<span class="warp-badge ${warp.enabled ? "active" : ""}" title="${escapeAttr(warp.source || "")}">${escapeHtml(warpLabel)}</span>` : ""}
</div> </div>
<div class="key-card-secret"> <div class="key-card-secret">
<span class="field-label">${escapeHtml(t("tableSecret"))}</span> <span class="field-label">${escapeHtml(t("tableSecret"))}</span>
@@ -1272,6 +1309,30 @@ function renderUsers() {
`; }).join(""); `; }).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) { function renderBackups(backups) {
const box = $("#backupsList"); const box = $("#backupsList");
renderBackupSchedule(); renderBackupSchedule();
@@ -1346,6 +1407,7 @@ async function refreshAll() {
try { try {
state.overview = await api("/api/overview"); state.overview = await api("/api/overview");
state.backupSchedule = state.overview.backup_schedule || state.backupSchedule; state.backupSchedule = state.overview.backup_schedule || state.backupSchedule;
state.warp = state.overview.warp || state.warp;
updateLanguageFromOverview(state.overview); updateLanguageFromOverview(state.overview);
state.users = await api("/api/users"); state.users = await api("/api/users");
ensureUserTrafficSelection(); ensureUserTrafficSelection();
@@ -1365,6 +1427,7 @@ async function refreshAll() {
} }
renderOverview(); renderOverview();
renderUsers(); renderUsers();
renderWarpSettings();
if (state.page === "traffic") { if (state.page === "traffic") {
await refreshStats(); await refreshStats();
} else if (state.page === "keys") { } 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() { async function createBackup() {
const btn = $("#createBackupBtn"); const btn = $("#createBackupBtn");
btn.disabled = true; btn.disabled = true;
@@ -1760,6 +1853,9 @@ $("#loadLogsBtn").addEventListener("click", loadLogs);
$("#repairStatsBtn").addEventListener("click", repairStats); $("#repairStatsBtn").addEventListener("click", repairStats);
$("#collectStatsBtn").addEventListener("click", collectStats); $("#collectStatsBtn").addEventListener("click", collectStats);
$("#authSettingsForm").addEventListener("submit", updateAuthSettings); $("#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)); window.addEventListener("hashchange", () => setPage((location.hash || "#dashboard").slice(1), false));
setPage((location.hash || "#dashboard").slice(1), false); setPage((location.hash || "#dashboard").slice(1), false);

View File

@@ -360,6 +360,43 @@
</form> </form>
</section> </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"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<div> <div>
@@ -403,6 +440,6 @@
<button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button> <button id="qrCopyBtn" type="button" class="soft" data-i18n="copyLink">Copy link</button>
</div> </div>
</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> </body>
</html> </html>

View File

@@ -916,6 +916,23 @@ h2 {
min-width: 0; 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-user { grid-area: user; }
.key-card-secret { grid-area: secret; } .key-card-secret { grid-area: secret; }
.key-card-links { grid-area: links; } .key-card-links { grid-area: links; }

View File

@@ -44,6 +44,9 @@ create_backup() {
if [ -f "$PCATELEGRAM_WEB_DIR/backup_schedule.json" ]; then if [ -f "$PCATELEGRAM_WEB_DIR/backup_schedule.json" ]; then
cp "$PCATELEGRAM_WEB_DIR/backup_schedule.json" "$tmp_dir/backup_schedule.json" 2>/dev/null cp "$PCATELEGRAM_WEB_DIR/backup_schedule.json" "$tmp_dir/backup_schedule.json" 2>/dev/null
fi 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) # Language marker (i18n)
if [ -f "$PCATELEGRAM_WEB_DIR/.language" ]; then 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 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 cp "$tmp_dir/opt/pcatelegram_web/disabled_users.json" "$backup_dir/disabled_users.json" 2>/dev/null || true
fi 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 if [ -f "$backup_dir/metadata.json" ]; then
@@ -303,6 +309,11 @@ restore_backup() {
esac esac
fi fi
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) # Восстанавливаем language marker (i18n)
if [ -f "$backup_dir/.language" ]; then if [ -f "$backup_dir/.language" ]; then