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_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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user