diff --git a/README.md b/README.md index 9b8d9d8..3cc2f01 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,17 @@ WARP+ key не отдается в API целиком: web показывает Per-client runtime routing в текущем telemt не включается автоматически: публичные параметры telemt дают users/limits/quotas/ad tags, но не документируют привязку upstream к конкретному user. Для настоящего WARP только одному клиенту нужен отдельный telemt route/service или upstream-схема. +## Порт и маскировка + +В web-admin Settings есть блок `Port and mask site`: + +- `Public port` — реальный порт `telemt` (`[server] port`) и порт в tg-ссылках (`[general.links] public_port`). +- `Mask site` — сайт маскировки FakeTLS (`[censorship] tls_domain`), который вшивается в `ee` secret. +- Перед сохранением web-admin проверяет порт через `ss`. Если порт занят не `telemt` (например, Xray / 3x-ui на 443), сохранение блокируется. +- После сохранения обновляются `/etc/telemt/config.toml`, `/opt/pcatelegram_web/config.json`, ссылки клиентов и перезапускается `telemt`. + +Один `telemt`-инстанс имеет один публичный порт и один сайт маскировки для всех клиентов. Разные порты или разные сайты маскировки на разных клиентов требуют отдельные `telemt` services/configs. + ## Проверки ```bash diff --git a/admin-web/server.py b/admin-web/server.py index 8f4066f..4891b40 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -60,6 +60,7 @@ SESSIONS: dict[str, float] = {} VERSION = "2.5.0" USER_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") LANG_RE = re.compile(r"^(en|ru)$") +HOST_RE = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$") SENSITIVE_CONFIG_KEYS = {"secret"} BACKUP_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+\.tar\.gz(\.enc)?$") MAX_UNIQUE_IP_LIMIT = 1000000 @@ -1029,6 +1030,118 @@ def read_telemt_edge_settings() -> dict[str, Any]: return settings +def normalize_port(value: Any) -> int: + try: + port = int(value) + except (TypeError, ValueError): + raise ValueError("port must be a number") from None + if port < 1 or port > 65535: + raise ValueError("port must be between 1 and 65535") + return port + + +def normalize_mask_host(value: Any) -> str: + host = str(value or "").strip().lower().rstrip(".") + if not HOST_RE.match(host): + raise ValueError("mask site must be a valid domain") + return host + + +def update_toml_scalar(section: str, key: str, literal: str) -> None: + TELEMT_CONFIG.parent.mkdir(parents=True, exist_ok=True) + lines = TELEMT_CONFIG.read_text(encoding="utf-8", errors="ignore").splitlines() if TELEMT_CONFIG.exists() else [] + header = f"[{section}]" + out: list[str] = [] + current = "" + found_section = False + wrote_key = False + + for raw in lines: + stripped = raw.strip() + if stripped.startswith("[") and stripped.endswith("]"): + if current == section and not wrote_key: + out.append(f"{key} = {literal}") + wrote_key = True + current = stripped.strip("[]") + if stripped == header: + found_section = True + out.append(raw) + continue + if current == section and stripped.startswith(key) and "=" in stripped: + out.append(f"{key} = {literal}") + wrote_key = True + continue + out.append(raw) + + if not found_section: + if out and out[-1].strip(): + out.append("") + out.append(header) + out.append(f"{key} = {literal}") + elif current == section and not wrote_key: + out.append(f"{key} = {literal}") + + tmp = TELEMT_CONFIG.with_name(TELEMT_CONFIG.name + ".tmp") + tmp.write_text("\n".join(out).rstrip() + "\n", encoding="utf-8") + os.chmod(tmp, 0o600) + tmp.replace(TELEMT_CONFIG) + + +def routing_payload(port: int | None = None) -> dict[str, Any]: + config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {} + settings = read_telemt_edge_settings() + current_port = int(port or config.get("port") or read_telemt_port() or 443) + mask_host = str(config.get("mask_host") or settings.get("tls_domain") or "google.com") + listeners, errors = collect_port_listeners(current_port) + conflicts = [ + item for item in listeners + if item.get("role") != "mtproxy" and "telemt" not in str(item.get("process", "")).lower() + ] + return { + "port": current_port, + "mask_host": mask_host, + "mask_port": int(settings.get("mask_port") or 443), + "mode": str(config.get("mode") or "lite"), + "domain": str(config.get("domain") or ""), + "listeners": listeners, + "conflicts": conflicts, + "ok": not errors, + "error": "; ".join(errors[:2]), + "per_user_ports_supported": False, + "note": "telemt has one server.port per instance; real per-client ports need multiple telemt services.", + } + + +def write_routing_settings(port: int, mask_host: str) -> dict[str, Any]: + listeners, _ = collect_port_listeners(port) + conflicts = [ + item for item in listeners + if item.get("role") != "mtproxy" and "telemt" not in str(item.get("process", "")).lower() + ] + if conflicts: + names = ", ".join(f"{item.get('process')} {item.get('address')}" for item in conflicts[:3]) + raise RuntimeError(f"port is busy: {names}") + + update_toml_scalar("server", "port", str(port)) + update_toml_scalar("general.links", "public_port", str(port)) + update_toml_scalar("censorship", "tls_domain", json.dumps(mask_host)) + + config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {} + if not isinstance(config, dict): + config = {} + config["port"] = port + config["mask_host"] = mask_host + config["updated_at"] = utc_now() + save_json(PCATELEGRAM_WEB_CONFIG, config) + + restarted = False + if service_status("telemt") != "not_installed": + restarted = request_service_restart("telemt") + payload = routing_payload(port) + payload["restart"] = {"requested": restarted} + return payload + + def load_shared443_config() -> dict[str, Any]: raw = load_json(SHARED_443_CONFIG, {}) or {} if not isinstance(raw, dict): @@ -1171,7 +1284,8 @@ def proxy_link(secret: str) -> str: mask_host = str(config.get("mask_host", "") or "") if mode == "pro" and domain: - host_hex = domain.encode().hex() + link_mask = mask_host or domain + host_hex = link_mask.encode().hex() return f"tg://proxy?server={domain}&port={port}&secret=ee{secret}{host_hex}" server = public_ip() @@ -1750,6 +1864,7 @@ def overview_payload() -> dict[str, Any]: "backups": list_backups(), "backup_schedule": backup_schedule_status(), "warp": public_warp_config(), + "routing": routing_payload(), } @@ -1868,6 +1983,15 @@ class AdminHandler(BaseHTTPRequestHandler): path = parsed.path if path == "/api/overview": self.send_json({"ok": True, "data": overview_payload()}) + elif path == "/api/routing": + qs = urllib.parse.parse_qs(parsed.query) + port_raw = qs.get("port", [""])[0] + try: + port = normalize_port(port_raw) if port_raw else None + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + self.send_json({"ok": True, "data": routing_payload(port)}) elif path == "/api/warp": self.send_json({"ok": True, "data": public_warp_config()}) elif path == "/api/users": @@ -2009,6 +2133,21 @@ class AdminHandler(BaseHTTPRequestHandler): return restart_requested = request_service_restart("telemt") self.send_json({"ok": True, "data": user_payload(name, secret, True, 0), "restart": {"mode": "async", "requested": restart_requested}}) + elif path == "/api/routing": + try: + port = normalize_port(body.get("port")) + mask_host = normalize_mask_host(body.get("mask_host")) + payload = write_routing_settings(port, mask_host) + except ValueError as exc: + self.send_error_json(400, str(exc)) + return + except RuntimeError as exc: + self.send_error_json(409, str(exc)) + return + except Exception as exc: + self.send_error_json(500, f"failed to save routing: {exc}") + return + self.send_json({"ok": True, "data": payload}) elif path.startswith("/api/users/") and path.endswith("/max-ips"): name = urllib.parse.unquote(path[len("/api/users/"):-len("/max-ips")]) try: diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 5e15015..02ec80b 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -106,6 +106,16 @@ const i18n = { authNewPassword: "New password", authSave: "Save login", authSaved: "Login updated", + routingEyebrow: "Network", + routingTitle: "Port and mask site", + routingPort: "Public port", + routingMaskHost: "Mask site", + routingSave: "Save routing", + routingSaved: "Routing saved", + routingOk: "port free", + routingBusy: "port busy", + routingPerUserNote: "One telemt instance has one public port and one mask site for all links. Different ports per client need separate telemt services.", + routingCurrentMask: "Mask site: {value}", warpEyebrow: "Routing", warpTitle: "WARP / WARP+", warpMode: "Mode", @@ -350,6 +360,16 @@ const i18n = { authNewPassword: "Новый пароль", authSave: "Сохранить вход", authSaved: "Данные входа обновлены", + routingEyebrow: "Сеть", + routingTitle: "Порт и маскировка", + routingPort: "Публичный порт", + routingMaskHost: "Сайт маскировки", + routingSave: "Сохранить маршрут", + routingSaved: "Маршрутизация сохранена", + routingOk: "порт свободен", + routingBusy: "порт занят", + routingPerUserNote: "Один telemt-инстанс имеет один публичный порт и один сайт маскировки для всех ссылок. Разные порты на клиентов требуют отдельные telemt-сервисы.", + routingCurrentMask: "Маскировка: {value}", warpEyebrow: "Маршрутизация", warpTitle: "WARP / WARP+", warpMode: "Режим", @@ -509,6 +529,7 @@ const state = { userTraffic: null, userTrafficLoading: false, backupSchedule: null, + routing: null, warp: null, qrLink: "", pendingUsers: new Set(), @@ -638,6 +659,7 @@ function applyI18n() { updateTrafficControls(); updateUserTrafficControls(); renderBackupSchedule(); + renderRoutingSettings(); renderWarpSettings(); updatePageTitle(); updateAutoRefreshToggle(); @@ -1383,8 +1405,11 @@ function renderEvents() { function renderConfig() { const cfg = state.overview?.config || {}; const site = state.overview?.site_status || {}; + const routing = state.routing || state.overview?.routing || {}; const items = [ [t("configMode"), cfg.mode || "--"], + [t("routingPort"), routing.port || cfg.port || "--"], + [t("routingMaskHost"), routing.mask_host || cfg.mask_host || cfg.domain || "--"], [t("configDomain"), cfg.domain || cfg.mask_host || "--"], [t("configSiteStatus"), siteStatusText(site)], [t("configTemplate"), cfg.template_id || cfg.template || "--"], @@ -1399,6 +1424,34 @@ function renderConfig() { `).join(""); } +function routingSummary(payload) { + const conflicts = payload?.conflicts || []; + if (conflicts.length) { + return `${t("routingBusy")}: ${conflicts.map((item) => `${item.process} ${item.address}`).join(", ")}`; + } + const listeners = payload?.listeners || []; + const listenerText = listeners.length + ? listeners.map((item) => `${item.process} ${item.address}`).join(", ") + : t("routingOk"); + const mask = t("routingCurrentMask").replace("{value}", payload?.mask_host || "--"); + return `${listenerText}. ${mask}. ${t("routingPerUserNote")}`; +} + +function renderRoutingSettings(payload = null) { + const routing = payload || state.routing || state.overview?.routing || {}; + const portEl = $("#routingPort"); + const maskEl = $("#routingMaskHost"); + if (!portEl || !maskEl) return; + if (!payload) { + portEl.value = routing.port || 443; + maskEl.value = routing.mask_host || "google.com"; + } + const conflicts = routing.conflicts || []; + $("#routingStatus").textContent = conflicts.length ? t("routingBusy") : t("routingOk"); + $("#routingStatus").className = `status-pill ${conflicts.length ? "health-error" : "health-ok"}`; + $("#routingRuntimeNote").textContent = routingSummary(routing); +} + async function refreshAll() { if (state.refreshingAll) return; state.refreshingAll = true; @@ -1407,6 +1460,7 @@ async function refreshAll() { try { state.overview = await api("/api/overview"); state.backupSchedule = state.overview.backup_schedule || state.backupSchedule; + state.routing = state.overview.routing || state.routing; state.warp = state.overview.warp || state.warp; updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); @@ -1427,6 +1481,7 @@ async function refreshAll() { } renderOverview(); renderUsers(); + renderRoutingSettings(); renderWarpSettings(); if (state.page === "traffic") { await refreshStats(); @@ -1585,6 +1640,45 @@ async function setUserMaxUniqueIps(name, value) { } } +async function checkRoutingPort() { + const port = Number.parseInt($("#routingPort").value, 10); + if (!Number.isFinite(port) || port < 1 || port > 65535) return; + try { + const data = await api(`/api/routing?port=${encodeURIComponent(port)}`); + renderRoutingSettings(data); + } catch (err) { + $("#routingStatus").textContent = t("routingBusy"); + $("#routingStatus").className = "status-pill health-error"; + $("#routingRuntimeNote").textContent = err.message; + } +} + +async function saveRoutingSettings(eventObj) { + eventObj.preventDefault(); + const form = eventObj.currentTarget; + const controls = Array.from(form.querySelectorAll("input, button")); + controls.forEach((control) => { control.disabled = true; }); + try { + const payload = { + port: Number.parseInt($("#routingPort").value, 10), + mask_host: $("#routingMaskHost").value.trim(), + }; + const data = await api("/api/routing", { + method: "POST", + body: JSON.stringify(payload), + }); + state.routing = data; + renderRoutingSettings(); + addEvent(t("routingSaved"), `${data.port} / ${data.mask_host}`); + toast(t("routingSaved")); + setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400); + } catch (err) { + toast(err.message); + } finally { + controls.forEach((control) => { control.disabled = false; }); + } +} + async function saveWarpSettings(eventObj) { eventObj.preventDefault(); const form = eventObj.currentTarget; @@ -1853,6 +1947,8 @@ $("#loadLogsBtn").addEventListener("click", loadLogs); $("#repairStatsBtn").addEventListener("click", repairStats); $("#collectStatsBtn").addEventListener("click", collectStats); $("#authSettingsForm").addEventListener("submit", updateAuthSettings); +$("#routingSettingsForm").addEventListener("submit", saveRoutingSettings); +$("#routingPort").addEventListener("change", checkRoutingPort); $("#warpSettingsForm").addEventListener("submit", saveWarpSettings); $("#warpScope").addEventListener("change", renderWarpSettings); $("#warpUserSelect").addEventListener("change", renderWarpSettings); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 1c2e710..16a68d3 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -117,6 +117,36 @@
+Network
+