mirror of
https://github.com/anten-ka/gotelegram_pro.git
synced 2026-06-14 18:52:46 +00:00
v2.5.0: harden admin key and traffic flows
This commit is contained in:
@@ -453,7 +453,7 @@ switch_language ru|en
|
|||||||
|
|
||||||
Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users/<name>/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`).
|
Функции: overview, проверка сайта на HTTP 200, service status/restart, чтение/запись `[access.users]`, enable/disable ключей через `/api/users/<name>/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`.
|
`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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
if not in_users or not line or line.startswith("#") or "=" not in line:
|
||||||
continue
|
continue
|
||||||
name, value = line.split("=", 1)
|
name, value = line.split("=", 1)
|
||||||
name = name.strip()
|
name = parse_toml_key(name)
|
||||||
value = value.strip().split("#", 1)[0].strip()
|
value = value.strip().split("#", 1)[0].strip()
|
||||||
if value.startswith('"') and '"' in value[1:]:
|
if value.startswith('"') and '"' in value[1:]:
|
||||||
value = value[1:].split('"', 1)[0]
|
value = value[1:].split('"', 1)[0]
|
||||||
@@ -221,7 +221,24 @@ def _ordered_user_lines(users: dict[str, str]) -> list[str]:
|
|||||||
if "main" in users:
|
if "main" in users:
|
||||||
names.append("main")
|
names.append("main")
|
||||||
names.extend(sorted(n for n in users if n != "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:
|
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:
|
def request_service_restart(name: str) -> bool:
|
||||||
try:
|
code, _, _ = run(["systemctl", "--no-block", "restart", name], timeout=5)
|
||||||
subprocess.Popen(
|
return code == 0
|
||||||
["systemctl", "restart", name],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def service_status(name: str) -> str:
|
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),
|
"points": len(window),
|
||||||
"from": first.get("epoch", 0),
|
"from": first.get("epoch", 0),
|
||||||
"to": last.get("epoch", 0),
|
"to": last.get("epoch", 0),
|
||||||
"proxy_delta": max(0, int(last.get("proxy_bytes", 0)) - int(first.get("proxy_bytes", 0))),
|
"proxy_delta": sum(max(0, int(item.get("proxy_delta", 0))) for item in window),
|
||||||
"site_delta": max(0, int(last.get("site_bytes", 0)) - int(first.get("site_bytes", 0))),
|
"site_delta": sum(max(0, int(item.get("site_delta", 0))) for item in window),
|
||||||
"proxy_total": int(last.get("proxy_bytes", 0)),
|
"proxy_total": int(last.get("proxy_bytes", 0)),
|
||||||
"site_total": int(last.get("site_bytes", 0)),
|
"site_total": int(last.get("site_bytes", 0)),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ const i18n = {
|
|||||||
port443NoListeners: "No 443 listeners found",
|
port443NoListeners: "No 443 listeners found",
|
||||||
port443Listeners: "listeners",
|
port443Listeners: "listeners",
|
||||||
port443Error: "Port check failed",
|
port443Error: "Port check failed",
|
||||||
|
port443Public: "public",
|
||||||
|
port443Configured: "telemt: {port}",
|
||||||
roleMtproxy: "MTProxy",
|
roleMtproxy: "MTProxy",
|
||||||
roleSite: "Website",
|
roleSite: "Website",
|
||||||
roleXray: "Xray / 3x-ui",
|
roleXray: "Xray / 3x-ui",
|
||||||
@@ -322,6 +324,8 @@ const i18n = {
|
|||||||
port443NoListeners: "Слушателей 443 не найдено",
|
port443NoListeners: "Слушателей 443 не найдено",
|
||||||
port443Listeners: "слушателей",
|
port443Listeners: "слушателей",
|
||||||
port443Error: "Проверка порта не удалась",
|
port443Error: "Проверка порта не удалась",
|
||||||
|
port443Public: "публичный",
|
||||||
|
port443Configured: "telemt: {port}",
|
||||||
roleMtproxy: "MTProxy",
|
roleMtproxy: "MTProxy",
|
||||||
roleSite: "Сайт",
|
roleSite: "Сайт",
|
||||||
roleXray: "Xray / 3x-ui",
|
roleXray: "Xray / 3x-ui",
|
||||||
@@ -374,6 +378,7 @@ const state = {
|
|||||||
theme: document.documentElement.dataset.theme || "light",
|
theme: document.documentElement.dataset.theme || "light",
|
||||||
trafficRange: "1h",
|
trafficRange: "1h",
|
||||||
trafficView: "chart",
|
trafficView: "chart",
|
||||||
|
trafficLoading: false,
|
||||||
pendingUsers: new Set(),
|
pendingUsers: new Set(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -634,6 +639,9 @@ function renderPort443(payload = {}) {
|
|||||||
const listeners = Array.isArray(payload.listeners) ? payload.listeners : [];
|
const listeners = Array.isArray(payload.listeners) ? payload.listeners : [];
|
||||||
const summary = $("#port443Summary");
|
const summary = $("#port443Summary");
|
||||||
const list = $("#port443List");
|
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) {
|
if (payload.error) {
|
||||||
summary.textContent = t("port443Error");
|
summary.textContent = t("port443Error");
|
||||||
summary.className = "port-status error";
|
summary.className = "port-status error";
|
||||||
@@ -760,14 +768,21 @@ function fallbackTrafficSummaries(rows) {
|
|||||||
return {
|
return {
|
||||||
range,
|
range,
|
||||||
points: windowRows.length,
|
points: windowRows.length,
|
||||||
proxy_delta: Math.max(0, (Number(last.proxy_bytes) || 0) - (Number(first.proxy_bytes) || 0)),
|
proxy_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.proxy_delta) || 0), 0),
|
||||||
site_delta: Math.max(0, (Number(last.site_bytes) || 0) - (Number(first.site_bytes) || 0)),
|
site_delta: windowRows.reduce((sum, item) => sum + Math.max(0, Number(item.site_delta) || 0), 0),
|
||||||
proxy_total: Number(last.proxy_bytes) || 0,
|
proxy_total: Number(last.proxy_bytes) || 0,
|
||||||
site_total: Number(last.site_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 = `<div class="empty-chart"><strong>${escapeHtml(t("loading"))}</strong></div>`;
|
||||||
|
$("#historyTable").innerHTML = `<tr><td colspan="5" class="empty-cell">${escapeHtml(t("loading"))}</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderStats() {
|
function renderStats() {
|
||||||
const payload = statsPayload();
|
const payload = statsPayload();
|
||||||
const status = payload.status || {};
|
const status = payload.status || {};
|
||||||
@@ -784,6 +799,10 @@ function renderStats() {
|
|||||||
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
|
$("#metricProxyTraffic").textContent = fmtBytes(stats.proxy_bytes);
|
||||||
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
|
$("#metricSiteTraffic").textContent = fmtBytes(stats.site_bytes);
|
||||||
updateTrafficControls();
|
updateTrafficControls();
|
||||||
|
if (state.trafficLoading) {
|
||||||
|
renderTrafficLoading();
|
||||||
|
return;
|
||||||
|
}
|
||||||
$("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart");
|
$("#trafficChart").classList.toggle("is-hidden", state.trafficView !== "chart");
|
||||||
$("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table");
|
$("#trafficTableWrap").classList.toggle("is-hidden", state.trafficView !== "table");
|
||||||
drawTrafficChart(historyRows);
|
drawTrafficChart(historyRows);
|
||||||
@@ -948,6 +967,9 @@ async function refreshAll() {
|
|||||||
}
|
}
|
||||||
renderOverview();
|
renderOverview();
|
||||||
renderUsers();
|
renderUsers();
|
||||||
|
if (state.page === "traffic") {
|
||||||
|
await refreshStats();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message);
|
toast(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -955,10 +977,39 @@ async function refreshAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStats() {
|
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)}`);
|
const data = await api(`/api/stats?range=${encodeURIComponent(state.trafficRange)}`);
|
||||||
state.stats = data;
|
state.stats = data;
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
state.trafficLoading = false;
|
||||||
renderStats();
|
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) {
|
async function addUser(name) {
|
||||||
@@ -992,15 +1043,18 @@ async function setUserEnabled(name, enabled) {
|
|||||||
const message = data.enabled ? t("keyEnabled") : t("keyDisabled");
|
const message = data.enabled ? t("keyEnabled") : t("keyDisabled");
|
||||||
addEvent(message, name);
|
addEvent(message, name);
|
||||||
toast(t("changesApplyInBackground"));
|
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) {
|
} catch (err) {
|
||||||
state.users = previousUsers;
|
state.users = previousUsers;
|
||||||
toast(err.message);
|
toast(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => {
|
|
||||||
state.pendingUsers.delete(name);
|
state.pendingUsers.delete(name);
|
||||||
renderUsers();
|
renderUsers();
|
||||||
}, 700);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,10 +1178,7 @@ document.addEventListener("click", async (eventObj) => {
|
|||||||
} else if (button.id === "menuBtn") {
|
} else if (button.id === "menuBtn") {
|
||||||
$("#sidebar").classList.toggle("open");
|
$("#sidebar").classList.toggle("open");
|
||||||
} else if (button.dataset.trafficRange) {
|
} else if (button.dataset.trafficRange) {
|
||||||
state.trafficRange = trafficRanges.includes(button.dataset.trafficRange) ? button.dataset.trafficRange : "1h";
|
changeTrafficRange(button.dataset.trafficRange);
|
||||||
updateTrafficControls();
|
|
||||||
renderStats();
|
|
||||||
refreshStats().catch((err) => toast(err.message));
|
|
||||||
} else if (button.dataset.trafficView) {
|
} else if (button.dataset.trafficView) {
|
||||||
state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart";
|
state.trafficView = button.dataset.trafficView === "table" ? "table" : "chart";
|
||||||
renderStats();
|
renderStats();
|
||||||
|
|||||||
@@ -67,7 +67,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="port-map" id="port443Map">
|
<div class="port-map" id="port443Map">
|
||||||
<div class="port-map-head">
|
<div class="port-map-head">
|
||||||
<span>443</span>
|
<div class="port-badge">
|
||||||
|
<span id="port443Number">443</span>
|
||||||
|
<small id="port443Configured">public</small>
|
||||||
|
</div>
|
||||||
<strong id="port443Summary">--</strong>
|
<strong id="port443Summary">--</strong>
|
||||||
</div>
|
</div>
|
||||||
<div id="port443List" class="port-list"></div>
|
<div id="port443List" class="port-list"></div>
|
||||||
|
|||||||
@@ -360,7 +360,12 @@ h2 {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.port-map-head span {
|
.port-badge {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-badge span {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 54px;
|
width: 54px;
|
||||||
@@ -372,6 +377,13 @@ h2 {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.port-badge small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.port-status {
|
.port-status {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -1163,6 +1175,7 @@ td small {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
|
display: flex;
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Supports EN/RU UI with per-user language preferences.
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import csv
|
import csv
|
||||||
|
import fcntl
|
||||||
import hashlib
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
@@ -104,6 +105,7 @@ logger = logging.getLogger(__name__)
|
|||||||
GOTELEGRAM_VERSION = "2.5.0"
|
GOTELEGRAM_VERSION = "2.5.0"
|
||||||
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
|
GOTELEGRAM_CONFIG = "/opt/gotelegram/config.json"
|
||||||
DISABLED_USERS_FILE = "/opt/gotelegram/disabled_users.json"
|
DISABLED_USERS_FILE = "/opt/gotelegram/disabled_users.json"
|
||||||
|
USER_LOCK_FILE = "/run/gotelegram/admin-users.lock"
|
||||||
TELEMT_CONFIG = "/etc/telemt/config.toml"
|
TELEMT_CONFIG = "/etc/telemt/config.toml"
|
||||||
TELEMT_SERVICE = "telemt"
|
TELEMT_SERVICE = "telemt"
|
||||||
WEBSITE_ROOT = "/var/www/gotelegram-site"
|
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}$")
|
_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:
|
async def run_bot_action(action: str, timeout: int = 300, **params) -> Dict:
|
||||||
"""Invoke install.sh --action=X --json and parse the JSON result.
|
"""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
|
# 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]:
|
def load_telemt_users() -> Dict[str, str]:
|
||||||
"""Return users from [access.users] in telemt config."""
|
"""Return users from [access.users] in telemt config."""
|
||||||
telemt_cfg = load_toml(TELEMT_CONFIG) or {}
|
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:
|
def save_telemt_users(users: Dict[str, str]) -> bool:
|
||||||
"""Persist [access.users] while keeping the rest of the TOML structure."""
|
"""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:
|
try:
|
||||||
os.makedirs(os.path.dirname(TELEMT_CONFIG), exist_ok=True)
|
os.makedirs(os.path.dirname(TELEMT_CONFIG), exist_ok=True)
|
||||||
with open(TELEMT_CONFIG, "w") as f:
|
if os.path.exists(TELEMT_CONFIG):
|
||||||
toml.dump(telemt_cfg, f)
|
with open(TELEMT_CONFIG, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
os.chmod(TELEMT_CONFIG, 0o600)
|
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
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save telemt users: {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:
|
async def refresh_telemt_after_user_change() -> bool:
|
||||||
"""Restart telemt after config user changes."""
|
"""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
|
return code == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -1783,6 +1840,7 @@ async def cb_user_toggle(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|||||||
if name == "main":
|
if name == "main":
|
||||||
await query.answer("main нельзя отключить", show_alert=True)
|
await query.answer("main нельзя отключить", show_alert=True)
|
||||||
return
|
return
|
||||||
|
with FileLock(USER_LOCK_FILE):
|
||||||
active = load_telemt_users()
|
active = load_telemt_users()
|
||||||
disabled = load_disabled_users()
|
disabled = load_disabled_users()
|
||||||
records = load_user_records()
|
records = load_user_records()
|
||||||
@@ -1835,6 +1893,7 @@ async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_T
|
|||||||
await query.answer()
|
await query.answer()
|
||||||
user_id = _uid(update)
|
user_id = _uid(update)
|
||||||
name = query.data.removeprefix("user_del_yes_")
|
name = query.data.removeprefix("user_del_yes_")
|
||||||
|
with FileLock(USER_LOCK_FILE):
|
||||||
active = load_telemt_users()
|
active = load_telemt_users()
|
||||||
disabled = load_disabled_users()
|
disabled = load_disabled_users()
|
||||||
records = load_user_records()
|
records = load_user_records()
|
||||||
@@ -1843,7 +1902,8 @@ async def cb_user_delete_confirm(update: Update, context: ContextTypes.DEFAULT_T
|
|||||||
return
|
return
|
||||||
active.pop(name, None)
|
active.pop(name, None)
|
||||||
disabled.pop(name, None)
|
disabled.pop(name, None)
|
||||||
if not save_telemt_users(active) or not save_disabled_users(disabled):
|
saved = save_telemt_users(active) and save_disabled_users(disabled)
|
||||||
|
if not saved:
|
||||||
await safe_edit_message(query, "❌ Не удалось сохранить config.toml")
|
await safe_edit_message(query, "❌ Не удалось сохранить config.toml")
|
||||||
return
|
return
|
||||||
await refresh_telemt_after_user_change()
|
await refresh_telemt_after_user_change()
|
||||||
@@ -1860,6 +1920,7 @@ async def create_user_from_text(update: Update, context: ContextTypes.DEFAULT_TY
|
|||||||
if not _USER_NAME_RE.match(name):
|
if not _USER_NAME_RE.match(name):
|
||||||
await update.message.reply_text("❌ Некорректное имя. Используйте латиницу, цифры, _ . - и до 48 символов.")
|
await update.message.reply_text("❌ Некорректное имя. Используйте латиницу, цифры, _ . - и до 48 символов.")
|
||||||
return
|
return
|
||||||
|
with FileLock(USER_LOCK_FILE):
|
||||||
records = load_user_records()
|
records = load_user_records()
|
||||||
if name in records:
|
if name in records:
|
||||||
await update.message.reply_text("❌ Такой пользователь уже есть.")
|
await update.message.reply_text("❌ Такой пользователь уже есть.")
|
||||||
@@ -1867,7 +1928,8 @@ async def create_user_from_text(update: Update, context: ContextTypes.DEFAULT_TY
|
|||||||
users = load_telemt_users()
|
users = load_telemt_users()
|
||||||
secret = hashlib.sha256(f"{name}:{time.time()}:{os.urandom(16).hex()}".encode()).hexdigest()[:32]
|
secret = hashlib.sha256(f"{name}:{time.time()}:{os.urandom(16).hex()}".encode()).hexdigest()[:32]
|
||||||
users[name] = secret
|
users[name] = secret
|
||||||
if not save_telemt_users(users):
|
saved = save_telemt_users(users)
|
||||||
|
if not saved:
|
||||||
await update.message.reply_text("❌ Не удалось сохранить /etc/telemt/config.toml")
|
await update.message.reply_text("❌ Не удалось сохранить /etc/telemt/config.toml")
|
||||||
return
|
return
|
||||||
await refresh_telemt_after_user_change()
|
await refresh_telemt_after_user_change()
|
||||||
|
|||||||
Reference in New Issue
Block a user