From be5656b2f2e3b13545d15cfad082921e6b4aecb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=91=D0=BE=D0=B1?= =?UTF-8?q?=D1=8B=D1=80=D0=B5=D0=B2?= Date: Sun, 7 Jun 2026 02:00:54 +0300 Subject: [PATCH] feat: manage mask site from admin --- README.md | 23 ++++ admin-web/server.py | 219 ++++++++++++++++++++++++++++++++++++ admin-web/static/app.js | 148 ++++++++++++++++++++++++ admin-web/static/index.html | 30 ++++- 4 files changed, 418 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3096bd3..2a42f2d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,8 @@ ssh root@SERVER 'chmod +x /opt/pcatelegram_web/install.sh /opt/pcatelegram_web/i | `PCATELEGRAM_WEB_ADMIN_USER` | `admin` | Basic Auth login | | `PCATELEGRAM_WEB_ADMIN_PASSWORD` | `admin` | web-admin password | | `PCATELEGRAM_WEB_WARP_CONFIG` | `/opt/pcatelegram_web/warp.json` | WARP / WARP+ settings | +| `PCATELEGRAM_WEB_SITE_ROOT` | `/var/www/pcatelegram_web-site` | root публичного HTML-сайта на 80 | +| `PCATELEGRAM_WEB_NGINX_MASK_CONF` | `/etc/nginx/sites-available/pcatelegram_web-mask` | nginx config сайта на 80 | ## Поддержать проект @@ -126,6 +128,27 @@ Per-client runtime routing в текущем telemt не включается а Один `telemt`-инстанс имеет один публичный порт и один сайт маскировки для всех клиентов. Разные порты или разные сайты маскировки на разных клиентов требуют отдельные `telemt` services/configs. +## Свой домен и сайт на 80 + +Чтобы домен открывал обычный публичный сайт: + +1. В панели регистратора домена создай DNS `A` запись: `@` → IP сервера. +2. Если нужен `www`, создай `A` запись `www` → IP сервера. +3. Дождись DNS. Обычно от нескольких минут до нескольких часов. +4. В web-admin открой `Settings` → `Domain and mask site`. +5. Введи домен без `http://`, например `example.ru`. +6. Нажми `Install site on 80`. + +Если порт `80` свободен, web-admin поставит/настроит nginx и отдаст нейтральный одностраничный HTML-сайт без упоминаний proxy, VPN, MTProxy или PCAtelegram_web. Если порт занят чужим процессом, сохранение будет заблокировано. + +Чтобы поставить свой HTML-сайт: + +1. В том же блоке выбери `.html` / `.htm` файл. +2. Нажми `Add HTML site`. +3. Проверь `http://DOMAIN/`. + +Лимит файла: 1 MB. Не клади в HTML секреты, токены, ключи и внутренние ссылки. Кнопка `Remove site` удаляет nginx config сайта с порта `80`, но не трогает `telemt` и ключи клиентов. + ## Ошибки и решения ### Telegram пишет `соединение` и proxy не подключается diff --git a/admin-web/server.py b/admin-web/server.py index 8407fce..164b300 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -48,6 +48,9 @@ SHARED_443_CONFIG = Path(os.getenv("PCATELEGRAM_WEB_SHARED_443", "/opt/pcatelegr 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")) WARP_CONFIG_FILE = Path(os.getenv("PCATELEGRAM_WEB_WARP_CONFIG", "/opt/pcatelegram_web/warp.json")) +WEBSITE_ROOT = Path(os.getenv("PCATELEGRAM_WEB_SITE_ROOT", "/var/www/pcatelegram_web-site")) +NGINX_MASK_CONF = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_CONF", "/etc/nginx/sites-available/pcatelegram_web-mask")) +NGINX_MASK_LINK = Path(os.getenv("PCATELEGRAM_WEB_NGINX_MASK_LINK", "/etc/nginx/sites-enabled/pcatelegram_web-mask")) HOST = os.getenv("PCATELEGRAM_WEB_ADMIN_HOST", "0.0.0.0") PORT = int(os.getenv("PCATELEGRAM_WEB_ADMIN_PORT", "1984")) @@ -1432,6 +1435,197 @@ def site_status(config: dict[str, Any] | None = None) -> dict[str, Any]: } +def default_mask_html(domain: str = "") -> str: + title = "Информационный центр" + subtitle = "Полезные материалы, новости и практические заметки для повседневной работы." + host = domain or "local" + return f""" + + + + + {title} + + + +
+
{title}
+ +
+
+
+

Коротко. Понятно. По делу.

+

{subtitle}

+
+
+

Материалы

Подборки и инструкции для людей, которым нужен быстрый ответ без лишнего шума.

+

Обновления

Заметки о полезных изменениях, сервисах и рабочих сценариях.

+

Контакты

Предложения и вопросы можно отправить владельцу сайта удобным способом.

+
+
+ + + +""" + + +def write_site_domain(domain: str) -> None: + config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {} + if not isinstance(config, dict): + config = {} + config["domain"] = domain + config["site_domain"] = domain + config["site_updated_at"] = utc_now() + save_json(PCATELEGRAM_WEB_CONFIG, config) + + +def site_port80_conflicts() -> list[dict[str, Any]]: + listeners, _ = collect_port_listeners(80) + return [ + item for item in listeners + if "nginx" not in str(item.get("process", "")).lower() + ] + + +def site_mask_status() -> dict[str, Any]: + config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {} + if not isinstance(config, dict): + config = {} + domain = str(config.get("site_domain") or config.get("domain") or "").strip() + index = WEBSITE_ROOT / "index.html" + listeners, errors = collect_port_listeners(80) + installed = NGINX_MASK_LINK.exists() and index.exists() + return { + "domain": domain, + "url": f"http://{domain}/" if domain else "", + "installed": installed, + "nginx": service_status("nginx"), + "port": 80, + "listeners": listeners, + "conflicts": site_port80_conflicts(), + "html_exists": index.exists(), + "html_size": index.stat().st_size if index.exists() else 0, + "html_mtime": int(index.stat().st_mtime) if index.exists() else 0, + "ok": not errors, + "error": "; ".join(errors[:2]), + "checked_at": int(time.time()), + } + + +def write_mask_nginx_config(domain: str) -> None: + server_name = domain if domain else "_" + NGINX_MASK_CONF.parent.mkdir(parents=True, exist_ok=True) + NGINX_MASK_LINK.parent.mkdir(parents=True, exist_ok=True) + conf = f"""# PCAtelegram_web public mask site on port 80 +server {{ + listen 80; + listen [::]:80; + server_name {server_name}; + + root {WEBSITE_ROOT}; + index index.html; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + location / {{ + try_files $uri $uri/ /index.html; + }} + + location ~ /\\. {{ + deny all; + }} +}} +""" + tmp = NGINX_MASK_CONF.with_suffix(".tmp") + tmp.write_text(conf, encoding="utf-8") + os.chmod(tmp, 0o644) + tmp.replace(NGINX_MASK_CONF) + try: + Path("/etc/nginx/sites-enabled/default").unlink() + except FileNotFoundError: + pass + except OSError: + pass + if NGINX_MASK_LINK.exists() or NGINX_MASK_LINK.is_symlink(): + NGINX_MASK_LINK.unlink() + NGINX_MASK_LINK.symlink_to(NGINX_MASK_CONF) + + +def install_mask_site(domain: str, html: str = "") -> dict[str, Any]: + if domain and not HOST_RE.match(domain): + raise ValueError("domain must be a valid domain or empty") + conflicts = site_port80_conflicts() + if conflicts: + names = ", ".join(f"{item.get('process')} {item.get('address')}" for item in conflicts[:3]) + raise RuntimeError(f"port 80 is busy: {names}") + if html and len(html.encode("utf-8")) > 1024 * 1024: + raise ValueError("html file is too large") + if not html: + html = default_mask_html(domain) + WEBSITE_ROOT.mkdir(parents=True, exist_ok=True) + index = WEBSITE_ROOT / "index.html" + tmp = index.with_suffix(".tmp") + tmp.write_text(html, encoding="utf-8") + os.chmod(tmp, 0o644) + tmp.replace(index) + write_site_domain(domain) + script = """ +set -euo pipefail +source /opt/pcatelegram_web/lib/common.sh +source /opt/pcatelegram_web/lib/i18n.sh +source /opt/pcatelegram_web/lib/website.sh +load_language "$(detect_language 2>/dev/null || echo en)" 2>/dev/null || true +ensure_deps >/tmp/pcatelegram_web-site-ensure-deps.log 2>&1 || true +install_nginx >/tmp/pcatelegram_web-site-nginx-install.log 2>&1 +""" + code, _, stderr = run_bash_env(script, timeout=180) + if code != 0: + raise RuntimeError((stderr.strip().splitlines()[-1:] or ["nginx install failed"])[0]) + write_mask_nginx_config(domain) + code, _, stderr = run(["nginx", "-t"], timeout=10) + if code != 0: + raise RuntimeError(stderr.strip() or "nginx config test failed") + code, _, stderr = run(["systemctl", "restart", "nginx"], timeout=30) + if code != 0: + raise RuntimeError(stderr.strip() or "nginx restart failed") + return site_mask_status() + + +def remove_mask_site() -> dict[str, Any]: + if NGINX_MASK_LINK.exists() or NGINX_MASK_LINK.is_symlink(): + NGINX_MASK_LINK.unlink() + if NGINX_MASK_CONF.exists(): + NGINX_MASK_CONF.unlink() + code, _, _ = run(["nginx", "-t"], timeout=10) + if code == 0: + run(["systemctl", "reload", "nginx"], timeout=15) + config = load_json(PCATELEGRAM_WEB_CONFIG, {}) or {} + if isinstance(config, dict): + config["site_enabled"] = False + config["site_updated_at"] = utc_now() + save_json(PCATELEGRAM_WEB_CONFIG, config) + return site_mask_status() + + def load_stats_history(limit: int | None = 240) -> list[dict[str, int]]: if not HISTORY_FILE.exists(): return [] @@ -1957,6 +2151,7 @@ def overview_payload() -> dict[str, Any]: "admin_bind": {"host": HOST, "port": PORT}, "config": public_config(config), "site_status": site_status(config), + "site_mask": site_mask_status(), "users_count": len(users), "services": services, "port_map": port_status(int(config.get("port") or read_telemt_port() or 443)), @@ -2194,6 +2389,8 @@ class AdminHandler(BaseHTTPRequestHandler): }) elif path == "/api/site/check": self.send_json({"ok": True, "data": site_status()}) + elif path == "/api/site/mask": + self.send_json({"ok": True, "data": site_mask_status()}) elif path == "/api/logs": qs = urllib.parse.parse_qs(parsed.query) service = qs.get("service", ["telemt"])[0] @@ -2252,6 +2449,28 @@ class AdminHandler(BaseHTTPRequestHandler): self.send_error_json(500, f"failed to save routing: {exc}") return self.send_json({"ok": True, "data": payload}) + elif path == "/api/site/mask": + action = str(body.get("action") or "install").strip().lower() + domain = str(body.get("domain") or "").strip().lower() + html = str(body.get("html") or "") + try: + if action in {"install", "upload"}: + payload = install_mask_site(domain, html) + elif action == "remove": + payload = remove_mask_site() + else: + self.send_error_json(400, "unsupported site action") + return + 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 manage site: {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 b6405b9..4b0e16c 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -133,6 +133,21 @@ const i18n = { warpPerUserNote: "One-client WARP profile is stored here. If warp-cli is missing, it will be installed on save. Runtime per-client routing needs a dedicated telemt route; global WARP applies to all clients.", warpAllNote: "Global WARP applies to all proxy traffic. If warp-cli is missing, it will be installed on save, then connected.", savedKey: "saved key", + siteMaskEyebrow: "Public site", + siteMaskTitle: "Domain and mask site", + siteMaskDomain: "Your domain", + siteMaskInstall: "Install site on 80", + siteMaskRemove: "Remove site", + siteMaskUpload: "Upload your HTML", + siteMaskUploadBtn: "Add HTML site", + siteMaskInstalled: "site installed", + siteMaskNotInstalled: "site not installed", + siteMaskBusy: "port 80 busy", + siteMaskReady: "port 80 ready", + siteMaskNote: "A neutral one-page site is served on port 80. It does not mention proxy or VPN. Point the A record to this server first.", + siteMaskUploaded: "HTML site uploaded", + siteMaskRemoved: "Site removed", + siteMaskSaved: "Site installed", dashboard: "Dashboard", noKeys: "No keys yet", noBackups: "No backups yet", @@ -388,6 +403,21 @@ const i18n = { warpPerUserNote: "Профиль WARP для одного клиента сохраняется здесь. Если warp-cli нет, он установится при сохранении. Runtime-маршрут на одного клиента требует отдельный маршрут telemt; global WARP действует на всех клиентов.", warpAllNote: "Global WARP применится ко всему proxy-трафику. Если warp-cli нет, он установится при сохранении и затем подключится.", savedKey: "ключ сохранён", + siteMaskEyebrow: "Публичный сайт", + siteMaskTitle: "Домен и сайт маскировки", + siteMaskDomain: "Свой домен", + siteMaskInstall: "Установить сайт на 80", + siteMaskRemove: "Удалить сайт", + siteMaskUpload: "Загрузить свой HTML", + siteMaskUploadBtn: "Добавить HTML сайт", + siteMaskInstalled: "сайт установлен", + siteMaskNotInstalled: "сайт не установлен", + siteMaskBusy: "порт 80 занят", + siteMaskReady: "порт 80 доступен", + siteMaskNote: "На порту 80 отдается нейтральный одностраничный сайт. Без упоминаний proxy/VPN. Сначала направьте A-запись домена на этот сервер.", + siteMaskUploaded: "HTML сайт загружен", + siteMaskRemoved: "Сайт удалён", + siteMaskSaved: "Сайт установлен", dashboard: "Обзор", noKeys: "Ключей пока нет", noBackups: "Бекапов пока нет", @@ -535,6 +565,7 @@ const state = { routingDirty: false, routingPreviewMap: null, warp: null, + siteMask: null, qrLink: "", pendingUsers: new Set(), refreshingAll: false, @@ -689,6 +720,7 @@ function applyI18n() { renderBackupSchedule(); renderRoutingSettings(); renderWarpSettings(); + renderSiteMaskSettings(); updatePageTitle(); updateAutoRefreshToggle(); } @@ -1390,6 +1422,39 @@ function renderWarpSettings() { : `${t("warpAllNote")}${statusLine ? ` ${statusLine}` : ""}`; } +function siteMaskSummary(payload = {}) { + const conflicts = payload.conflicts || []; + if (conflicts.length) { + return `${t("siteMaskBusy")}: ${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("siteMaskReady"); + const domain = payload.domain || "--"; + const html = payload.html_exists ? fmtBytes(payload.html_size || 0) : "--"; + return `${listenerText}. domain: ${domain}. html: ${html}. ${t("siteMaskNote")}`; +} + +function renderSiteMaskSettings(payload = null) { + const cfg = payload || state.siteMask || state.overview?.site_mask || {}; + const domainEl = $("#siteMaskDomain"); + const statusEl = $("#siteMaskStatus"); + const noteEl = $("#siteMaskNote"); + if (!domainEl || !statusEl || !noteEl) return; + if (document.activeElement !== domainEl) { + domainEl.value = cfg.domain || ""; + } + const conflicts = cfg.conflicts || []; + const installed = Boolean(cfg.installed); + statusEl.textContent = conflicts.length + ? t("siteMaskBusy") + : (installed ? t("siteMaskInstalled") : t("siteMaskNotInstalled")); + statusEl.className = `status-pill ${conflicts.length ? "health-error" : (installed ? "health-ok" : "")}`; + noteEl.textContent = siteMaskSummary(cfg); + noteEl.classList.toggle("error", Boolean(conflicts.length)); +} + function renderBackups(backups) { const box = $("#backupsList"); renderBackupSchedule(); @@ -1547,6 +1612,7 @@ async function refreshAll(options = {}) { state.backupSchedule = state.overview.backup_schedule || state.backupSchedule; state.routing = state.overview.routing || state.routing; state.warp = state.overview.warp || state.warp; + state.siteMask = state.overview.site_mask || state.siteMask; updateLanguageFromOverview(state.overview); state.users = await api("/api/users"); ensureUserTrafficSelection(); @@ -1572,6 +1638,7 @@ async function refreshAll(options = {}) { renderUsers(); renderRoutingSettings(); renderWarpSettings(); + renderSiteMaskSettings(); if (state.page === "traffic") { await refreshStats(); } else if (state.page === "keys") { @@ -1817,6 +1884,84 @@ async function saveWarpSettings(eventObj) { } } +async function saveSiteMask(eventObj) { + eventObj.preventDefault(); + const form = eventObj.currentTarget; + const controls = Array.from(form.querySelectorAll("input, button")); + controls.forEach((control) => { control.disabled = true; }); + try { + const data = await api("/api/site/mask", { + method: "POST", + body: JSON.stringify({ + action: "install", + domain: $("#siteMaskDomain").value.trim().toLowerCase(), + }), + }); + state.siteMask = data; + renderSiteMaskSettings(); + addEvent(t("siteMaskSaved"), data.url || data.domain || "80"); + toast(t("siteMaskSaved")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + controls.forEach((control) => { control.disabled = false; }); + } +} + +async function removeSiteMask() { + const btn = $("#siteMaskRemoveBtn"); + if (!window.confirm(t("siteMaskRemove"))) return; + btn.disabled = true; + try { + const data = await api("/api/site/mask", { + method: "POST", + body: JSON.stringify({ action: "remove" }), + }); + state.siteMask = data; + renderSiteMaskSettings(); + addEvent(t("siteMaskRemoved"), "80"); + toast(t("siteMaskRemoved")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + +async function uploadSiteMaskHtml() { + const fileEl = $("#siteMaskHtmlFile"); + const file = fileEl?.files?.[0]; + if (!file) { + toast(t("siteMaskUpload")); + return; + } + const btn = $("#siteMaskUploadBtn"); + btn.disabled = true; + try { + const html = await file.text(); + const data = await api("/api/site/mask", { + method: "POST", + body: JSON.stringify({ + action: "upload", + domain: $("#siteMaskDomain").value.trim().toLowerCase(), + html, + }), + }); + state.siteMask = data; + fileEl.value = ""; + renderSiteMaskSettings(); + addEvent(t("siteMaskUploaded"), file.name); + toast(t("siteMaskUploaded")); + await refreshAll(); + } catch (err) { + toast(err.message); + } finally { + btn.disabled = false; + } +} + async function createBackup() { const btn = $("#createBackupBtn"); btn.disabled = true; @@ -2066,6 +2211,9 @@ $("#routingPort").addEventListener("change", (eventObj) => checkRoutingPort(even $("#warpSettingsForm").addEventListener("submit", saveWarpSettings); $("#warpScope").addEventListener("change", renderWarpSettings); $("#warpUserSelect").addEventListener("change", renderWarpSettings); +$("#siteMaskForm").addEventListener("submit", saveSiteMask); +$("#siteMaskRemoveBtn").addEventListener("click", removeSiteMask); +$("#siteMaskUploadBtn").addEventListener("click", uploadSiteMaskHtml); document.addEventListener("focusout", () => { setTimeout(runDeferredRefresh, 120); }); diff --git a/admin-web/static/index.html b/admin-web/static/index.html index 2f23ca1..d2b2b2b 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -11,7 +11,7 @@ document.documentElement.dataset.theme = theme; }()); - +
@@ -129,6 +129,32 @@
+
+
+
+

Public site

+

Domain and mask site

+
+ -- +
+
+ + +
+ + +
+ + +
+
+
@@ -482,6 +508,6 @@
- +