diff --git a/DOCS_AI.md b/DOCS_AI.md index 4681178..c446c88 100644 --- a/DOCS_AI.md +++ b/DOCS_AI.md @@ -453,7 +453,7 @@ switch_language ru|en Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users//enabled`, генерация proxy links, traffic history из `/opt/gotelegram/stats_history.csv` с периодами 15m/1h/24h/month, current stats из `/run/gotelegram/stats_current.json`, список/создание backup, структурированные journal logs (`service`, `ok`, `exit_code`, `line_count`, `text`). -Отключённые ключи хранятся в `/opt/gotelegram/disabled_users.json`: active keys остаются в `/etc/telemt/config.toml` под `[access.users]`, disabled keys удаляются из active block и могут быть возвращены обратно без потери secret. `main` защищён от удаления и отключения. Операции с ключами берут file lock `/run/gotelegram/admin-users.lock`, TOML пишется через temp+replace, а telemt restart для add/delete/enable/disable запускается асинхронно, чтобы switch в UI не зависал на `wait_tcp_port`. +Отключённые ключи хранятся в `/opt/gotelegram/disabled_users.json`: active keys остаются в `/etc/telemt/config.toml` под `[access.users]`, disabled keys удаляются из active block и могут быть возвращены обратно без потери secret. `main` защищён от удаления и отключения. Операции с ключами в web-admin и Telegram-боте берут общий file lock `/run/gotelegram/admin-users.lock`, TOML пишется через temp+replace и quoted keys (`"a.b"`), а telemt restart для add/delete/enable/disable ставится через `systemctl --no-block restart`, чтобы switch в UI не зависал на `wait_tcp_port`. `install_admin_web` вызывается при установке Telegram-бота. `auto_install_admin_web_if_possible` подхватывает админку после bootstrap/update, если Python уже установлен и файлы отличаются. При установке админки скрипт пытается установить/перезапустить `gotelegram-stats`; если это не удалось, оператор может нажать Restart collector в Traffic. Backup v1.4 сохраняет `admin_web/server.py`, `admin_web/static/` и `disabled_users.json`, restore возвращает их, удаляет legacy `admin_web/token` и пробует перезапустить `gotelegram-admin`. diff --git a/admin-web/server.py b/admin-web/server.py index ab34b91..31c46d6 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -167,7 +167,7 @@ def read_telemt_users() -> dict[str, str]: if not in_users or not line or line.startswith("#") or "=" not in line: continue name, value = line.split("=", 1) - name = name.strip() + name = parse_toml_key(name) value = value.strip().split("#", 1)[0].strip() if value.startswith('"') and '"' in value[1:]: value = value[1:].split('"', 1)[0] @@ -221,7 +221,24 @@ def _ordered_user_lines(users: dict[str, str]) -> list[str]: if "main" in users: names.append("main") names.extend(sorted(n for n in users if n != "main")) - return [f'{name} = "{users[name]}"' for name in names] + return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names] + + +def parse_toml_key(raw: str) -> str: + key = raw.strip() + if len(key) >= 2 and key[0] == key[-1] == '"': + try: + return json.loads(key) + except json.JSONDecodeError: + return key[1:-1].replace('\\"', '"').replace("\\\\", "\\") + if len(key) >= 2 and key[0] == key[-1] == "'": + return key[1:-1] + return key + + +def quote_toml_key(name: str) -> str: + escaped = name.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' def write_telemt_users(users: dict[str, str]) -> None: @@ -267,16 +284,8 @@ def restart_service(name: str) -> bool: def request_service_restart(name: str) -> bool: - try: - subprocess.Popen( - ["systemctl", "restart", name], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - return True - except Exception: - return False + code, _, _ = run(["systemctl", "--no-block", "restart", name], timeout=5) + return code == 0 def service_status(name: str) -> str: @@ -539,8 +548,8 @@ def traffic_interval_summaries(rows: list[dict[str, int]]) -> list[dict[str, Any "points": len(window), "from": first.get("epoch", 0), "to": last.get("epoch", 0), - "proxy_delta": max(0, int(last.get("proxy_bytes", 0)) - int(first.get("proxy_bytes", 0))), - "site_delta": max(0, int(last.get("site_bytes", 0)) - int(first.get("site_bytes", 0))), + "proxy_delta": sum(max(0, int(item.get("proxy_delta", 0))) for item in window), + "site_delta": sum(max(0, int(item.get("site_delta", 0))) for item in window), "proxy_total": int(last.get("proxy_bytes", 0)), "site_total": int(last.get("site_bytes", 0)), }) diff --git a/admin-web/static/app.js b/admin-web/static/app.js index 5184a78..6324fc2 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -142,6 +142,8 @@ const i18n = { port443NoListeners: "No 443 listeners found", port443Listeners: "listeners", port443Error: "Port check failed", + port443Public: "public", + port443Configured: "telemt: {port}", roleMtproxy: "MTProxy", roleSite: "Website", roleXray: "Xray / 3x-ui", @@ -322,6 +324,8 @@ const i18n = { port443NoListeners: "Слушателей 443 не найдено", port443Listeners: "слушателей", port443Error: "Проверка порта не удалась", + port443Public: "публичный", + port443Configured: "telemt: {port}", roleMtproxy: "MTProxy", roleSite: "Сайт", roleXray: "Xray / 3x-ui", @@ -374,6 +378,7 @@ const state = { theme: document.documentElement.dataset.theme || "light", trafficRange: "1h", trafficView: "chart", + trafficLoading: false, pendingUsers: new Set(), }; @@ -634,6 +639,9 @@ function renderPort443(payload = {}) { const listeners = Array.isArray(payload.listeners) ? payload.listeners : []; const summary = $("#port443Summary"); const list = $("#port443List"); + const configuredPort = Number(payload.configured_port) || 443; + $("#port443Number").textContent = "443"; + $("#port443Configured").textContent = configuredPort === 443 ? t("port443Public") : t("port443Configured").replace("{port}", configuredPort); if (payload.error) { summary.textContent = t("port443Error"); summary.className = "port-status error"; @@ -760,14 +768,21 @@ function fallbackTrafficSummaries(rows) { return { range, points: windowRows.length, - proxy_delta: Math.max(0, (Number(last.proxy_bytes) || 0) - (Number(first.proxy_bytes) || 0)), - site_delta: Math.max(0, (Number(last.site_bytes) || 0) - (Number(first.site_bytes) || 0)), + proxy_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.proxy_delta) || 0), 0), + site_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.site_delta) || 0), 0), proxy_total: Number(last.proxy_bytes) || 0, site_total: Number(last.site_bytes) || 0, }; }); } +function renderTrafficLoading() { + $("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart"); + $("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table"); + $("#trafficChart").innerHTML = `
${escapeHtml(t("loading"))}
`; + $("#historyTable").innerHTML = `${escapeHtml(t("loading"))}`; +} + function renderStats() { const payload = statsPayload(); const status = payload.status || {}; @@ -784,6 +799,10 @@ function renderStats() { $("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes); $("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes); updateTrafficControls(); + if (state.trafficLoading) { + renderTrafficLoading(); + return; + } $("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart"); $("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table"); drawTrafficChart(historyRows); @@ -948,6 +967,9 @@ async function refreshAll() { } renderOverview(); renderUsers(); + if (state.page === "traffic") { + await refreshStats(); + } } catch (err) { toast(err.message); } finally { @@ -955,10 +977,39 @@ async function refreshAll() { } } -async function refreshStats() { - const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`); - state.stats = data; - renderStats(); +async function refreshUsers() { + state.users = await api("/api/users"); + renderUsers(); +} + +async function refreshStats(options = {}) { + if (options.showLoading) { + state.trafficLoading = true; + renderStats(); + } + try { + const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`); + state.stats = data; + return data; + } finally { + state.trafficLoading = false; + renderStats(); + } +} + +async function changeTrafficRange(range) { + const next = trafficRanges.includes(range) ? range : "1h"; + if (next === state.trafficRange && state.stats?.range === next) return; + const previous = state.trafficRange; + state.trafficRange = next; + try { + await refreshStats({ showLoading: true }); + } catch (err) { + state.trafficRange = previous; + state.trafficLoading = false; + renderStats(); + toast(err.message); + } } async function addUser(name) { @@ -992,15 +1043,18 @@ async function setUserEnabled(name, enabled) { const message = data.enabled ? t("keyEnabled") : t("keyDisabled"); addEvent(message, name); toast(t("changesApplyInBackground")); - setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1200); + try { + await refreshUsers(); + } catch (refreshErr) { + toast(refreshErr.message); + } + setTimeout(() => refreshAll().catch((err) => toast(err.message)), 1400); } catch (err) { state.users = previousUsers; toast(err.message); } finally { - setTimeout(() => { - state.pendingUsers.delete(name); - renderUsers(); - }, 700); + state.pendingUsers.delete(name); + renderUsers(); } } @@ -1124,10 +1178,7 @@ document.addEventListener("click", async (eventObj) => { } else if (button.id === "menuBtn") { $("#sidebar").classList.toggle("open"); } else if (button.dataset.trafficRange) { - state.trafficRange = trafficRanges.includes(button.dataset.trafficRange) ? button.dataset.trafficRange : "1h"; - updateTrafficControls(); - renderStats(); - refreshStats().catch((err) => toast(err.message)); + changeTrafficRange(button.dataset.trafficRange); } else if (button.dataset.trafficView) { state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart"; renderStats(); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index a649359..a631986 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -67,7 +67,10 @@
- 443 +
+ 443 + public +
--
diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index 68335c9..9e32e65 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -360,7 +360,12 @@ h2 { gap: 12px; } -.port-map-head span { +.port-badge { + display: grid; + gap: 4px; +} + +.port-badge span { display: inline-grid; place-items: center; width: 54px; @@ -372,6 +377,13 @@ h2 { font-size: 20px; } +.port-badge small { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-align: center; +} + .port-status { color: var(--muted); font-size: 12px; @@ -1163,6 +1175,7 @@ td small { } .actions { + display: flex; justify-content: stretch; flex-wrap: wrap; } diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 661f807..29a418f 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -8,6 +8,7 @@ Supports EN/RU UI with per-user language preferences. import asyncio import csv +import fcntl import hashlib import html import json @@ -104,6 +105,7 @@ logger = logging.getLogger(__name__) GOTELEGRAM_VERSION = "2.5.0" GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json" DISABLED_USERS_FILE = "/opt/gotelegram/disabled_users.json" +USER_LOCK_FILE = "/run/gotelegram/admin-users.lock" TELEMT_CONFIG = "/etc/telemt/config.toml" TELEMT_SERVICE = "telemt" WEBSITE_ROOT = "/var/www/gotelegram-site" @@ -264,6 +266,23 @@ _DOMAIN_RE = re.compile( _USER_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,48}$") +class FileLock: + def __init__(self, path: str): + self.path = Path(path) + self.handle = None + + def __enter__(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + self.handle = self.path.open("w", encoding="utf-8") + fcntl.flock(self.handle.fileno(), fcntl.LOCK_EX) + return self + + def __exit__(self, exc_type, exc, tb): + if self.handle: + fcntl.flock(self.handle.fileno(), fcntl.LOCK_UN) + self.handle.close() + + async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict: """Invoke install.sh --action=X --json and parse the JSON result. @@ -1401,6 +1420,19 @@ async def cb_pro_confirm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> # PROXY LINK & SHARE # ============================================================================ +def quote_toml_key(name: str) -> str: + escaped = name.replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + + +def ordered_user_lines(users: Dict[str, str]) -> List[str]: + names: List[str] = [] + if "main" in users: + names.append("main") + names.extend(sorted(name for name in users if name != "main")) + return [f'{quote_toml_key(name)} = "{users[name]}"' for name in names] + + def load_telemt_users() -> Dict[str, str]: """Return users from [access.users] in telemt config.""" telemt_cfg = load_toml(TELEMT_CONFIG) or {} @@ -1458,14 +1490,39 @@ def load_user_records() -> Dict[str, Dict[str, Any]]: def save_telemt_users(users: Dict[str, str]) -> bool: """Persist [access.users] while keeping the rest of the TOML structure.""" - telemt_cfg = load_toml(TELEMT_CONFIG) or {} - access = telemt_cfg.setdefault("access", {}) - access["users"] = dict(sorted(users.items())) try: os.makedirs(os.path.dirname(TELEMT_CONFIG), exist_ok=True) - with open(TELEMT_CONFIG, "w") as f: - toml.dump(telemt_cfg, f) - os.chmod(TELEMT_CONFIG, 0o600) + if os.path.exists(TELEMT_CONFIG): + with open(TELEMT_CONFIG, "r", encoding="utf-8", errors="ignore") as f: + lines = f.read().splitlines() + else: + lines = [] + rendered = ordered_user_lines(users) + out: List[str] = [] + in_users = False + found = False + for raw in lines: + if raw.strip() == "[access.users]": + found = True + in_users = True + out.append(raw) + out.extend(rendered) + continue + if in_users and raw.strip().startswith("["): + in_users = False + if in_users: + continue + out.append(raw) + if not found: + if out and out[-1].strip(): + out.append("") + out.append("[access.users]") + out.extend(rendered) + tmp = f"{TELEMT_CONFIG}.tmp" + with open(tmp, "w", encoding="utf-8") as f: + f.write("\n".join(out).rstrip() + "\n") + os.chmod(tmp, 0o600) + os.replace(tmp, TELEMT_CONFIG) return True except Exception as e: logger.error(f"Failed to save telemt users: {e}") @@ -1474,7 +1531,7 @@ def save_telemt_users(users: Dict[str, str]) -> bool: async def refresh_telemt_after_user_change() -> bool: """Restart telemt after config user changes.""" - code, _, _ = await sh("systemctl", "restart", TELEMT_SERVICE, timeout=20) + code, _, _ = await sh("systemctl", "--no-block", "restart", TELEMT_SERVICE, timeout=5) return code == 0 @@ -1783,25 +1840,26 @@ async def cb_user_toggle(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if name == "main": await query.answer("main нельзя отключить", show_alert=True) return - active = load_telemt_users() - disabled = load_disabled_users() - records = load_user_records() - record = records.get(name) - if not record: - await query.answer("Ключ не найден", show_alert=True) - return - enabled = not bool(record.get("enabled")) - secret = str(record.get("secret", "")) - if enabled: - disabled.pop(name, None) - active[name] = secret - else: - active.pop(name, None) - disabled[name] = secret - if enabled: - saved = save_telemt_users(active) and save_disabled_users(disabled) - else: - saved = save_disabled_users(disabled) and save_telemt_users(active) + with FileLock(USER_LOCK_FILE): + active = load_telemt_users() + disabled = load_disabled_users() + records = load_user_records() + record = records.get(name) + if not record: + await query.answer("Ключ не найден", show_alert=True) + return + enabled = not bool(record.get("enabled")) + secret = str(record.get("secret", "")) + if enabled: + disabled.pop(name, None) + active[name] = secret + else: + active.pop(name, None) + disabled[name] = secret + if enabled: + saved = save_telemt_users(active) and save_disabled_users(disabled) + else: + saved = save_disabled_users(disabled) and save_telemt_users(active) if not saved: await safe_edit_message(query, "❌ Не удалось сохранить состояние ключа") return @@ -1835,15 +1893,17 @@ async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_T await query.answer() user_id = _uid(update) name = query.data.removeprefix("user_del_yes_") - active = load_telemt_users() - disabled = load_disabled_users() - records = load_user_records() - if name == "main" or name not in records: - await query.answer("Нельзя удалить этот ключ", show_alert=True) - return - active.pop(name, None) - disabled.pop(name, None) - if not save_telemt_users(active) or not save_disabled_users(disabled): + with FileLock(USER_LOCK_FILE): + active = load_telemt_users() + disabled = load_disabled_users() + records = load_user_records() + if name == "main" or name not in records: + await query.answer("Нельзя удалить этот ключ", show_alert=True) + return + active.pop(name, None) + disabled.pop(name, None) + saved = save_telemt_users(active) and save_disabled_users(disabled) + if not saved: await safe_edit_message(query, "❌ Не удалось сохранить config.toml") return await refresh_telemt_after_user_change() @@ -1860,14 +1920,16 @@ async def create_user_from_text(update: Update, context: ContextTypes.DEFAULT_TY if not _USER_NAME_RE.match(name): await update.message.reply_text("❌ Некорректное имя. Используйте латиницу, цифры, _ . - и до 48 символов.") return - records = load_user_records() - if name in records: - await update.message.reply_text("❌ Такой пользователь уже есть.") - return - users = load_telemt_users() - secret = hashlib.sha256(f"{name}:{time.time()}:{os.urandom(16).hex()}".encode()).hexdigest()[:32] - users[name] = secret - if not save_telemt_users(users): + with FileLock(USER_LOCK_FILE): + records = load_user_records() + if name in records: + await update.message.reply_text("❌ Такой пользователь уже есть.") + return + users = load_telemt_users() + secret = hashlib.sha256(f"{name}:{time.time()}:{os.urandom(16).hex()}".encode()).hexdigest()[:32] + users[name] = secret + saved = save_telemt_users(users) + if not saved: await update.message.reply_text("❌ Не удалось сохранить /etc/telemt/config.toml") return await refresh_telemt_after_user_change()