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}
+
+
+
+
+
+
+ Коротко. Понятно. По делу.
+ {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 @@
-
+