diff --git a/cluster/hub_server.py b/cluster/hub_server.py index 6440ea1..b9963d3 100644 --- a/cluster/hub_server.py +++ b/cluster/hub_server.py @@ -208,7 +208,9 @@ def create_enrollment_code(ttl: int = ENROLLMENT_TTL) -> tuple[str, int]: def enroll_node(payload: dict[str, Any]) -> dict[str, Any]: - pairing_code = str(payload.get("pairing_code") or "") + # Codes are commonly copied from Telegram. Ignore formatting whitespace + # added by the client while keeping the token itself exact. + pairing_code = re.sub(r"\s+", "", str(payload.get("pairing_code") or "")) label = clean_label(payload.get("label")) proxy_url = str(payload.get("proxy_url") or "").strip() if not is_proxy_url(proxy_url): diff --git a/cluster/node_agent.py b/cluster/node_agent.py index 4daf4e0..ad7acc3 100644 --- a/cluster/node_agent.py +++ b/cluster/node_agent.py @@ -3,11 +3,13 @@ from __future__ import annotations +import argparse import hashlib import json import os import socket import subprocess +import sys import tempfile import time import urllib.error @@ -30,6 +32,12 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): HTTP_OPENER = urllib.request.build_opener(NoRedirectHandler()) +class HubRequestError(RuntimeError): + def __init__(self, status: int, message: str): + super().__init__(message) + self.status = status + + def load_json(path: Path, default: Any) -> Any: try: return json.loads(path.read_text(encoding="utf-8")) @@ -140,10 +148,20 @@ def request(config: dict[str, Any], endpoint: str, payload: dict[str, Any], toke raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") req = urllib.request.Request(f"{base}/{endpoint.lstrip('/')}", raw, headers, method="POST") # Bearer must never follow a redirect to another origin. - with HTTP_OPENER.open(req, timeout=TIMEOUT) as response: - body = json.loads(response.read(MAX_RESPONSE)) + try: + with HTTP_OPENER.open(req, timeout=TIMEOUT) as response: + body = json.loads(response.read(MAX_RESPONSE)) + except urllib.error.HTTPError as exc: + message = f"hub returned HTTP {exc.code}" + try: + error_body = json.loads(exc.read(MAX_RESPONSE)) + if isinstance(error_body, dict) and error_body.get("error"): + message = str(error_body["error"]) + except (OSError, ValueError, json.JSONDecodeError): + pass + raise HubRequestError(int(exc.code), message) from exc if not body.get("ok"): - raise RuntimeError(str(body.get("error") or "hub rejected request")) + raise HubRequestError(0, str(body.get("error") or "hub rejected request")) return body.get("data") or {} @@ -163,7 +181,7 @@ def enroll(config: dict[str, Any]) -> dict[str, Any]: config, "v1/enroll", { - "pairing_code": str(config.get("pairing_code") or ""), + "pairing_code": "".join(str(config.get("pairing_code") or "").split()), "install_id": config["install_id"], "label": str(config.get("label") or socket.gethostname()), "proxy_url": proxy_url(), @@ -179,25 +197,29 @@ def enroll(config: dict[str, Any]) -> dict[str, Any]: return config +def sync_once() -> dict[str, Any]: + config = load_json(CONFIG_PATH, {}) + ensure_install_id(config) + if not config.get("node_token"): + config = enroll(config) + request( + config, + "v1/heartbeat", + { + "label": str(config.get("label") or socket.gethostname()), + "proxy_url": proxy_url(), + "status": status_payload(), + }, + str(config.get("node_token") or ""), + ) + return config + + def run() -> None: failures = 0 while True: - config = load_json(CONFIG_PATH, {}) - ensure_install_id(config) try: - if not config.get("node_token"): - config = enroll(config) - print(f"IGProxy node enrolled as {config.get('node_id')}", flush=True) - request( - config, - "v1/heartbeat", - { - "label": str(config.get("label") or socket.gethostname()), - "proxy_url": proxy_url(), - "status": status_payload(), - }, - str(config.get("node_token") or ""), - ) + config = sync_once() failures = 0 delay = max(15, min(int(config.get("heartbeat_interval") or 30), 300)) except (OSError, ValueError, KeyError, RuntimeError, urllib.error.URLError) as exc: @@ -207,5 +229,32 @@ def run() -> None: time.sleep(delay) +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--once", + action="store_true", + help="perform enrollment/heartbeat once and exit", + ) + args = parser.parse_args() + if not args.once: + run() + return 0 + try: + config = sync_once() + print(f"IGProxy node connected as {config.get('node_id')}", flush=True) + return 0 + except HubRequestError as exc: + print(f"IGProxy node rejected: {exc}", file=sys.stderr, flush=True) + return 10 if exc.status == 401 else 1 + except (OSError, ValueError, KeyError, RuntimeError, urllib.error.URLError) as exc: + print( + f"IGProxy node connection failed: {type(exc).__name__}: {exc}", + file=sys.stderr, + flush=True, + ) + return 1 + + if __name__ == "__main__": - run() + raise SystemExit(main()) diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 3b97f89..16c6451 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -808,12 +808,16 @@ def create_cluster_pairing_code() -> Dict[str, Any]: if not IGPROXY_HUB_CLI.is_file(): raise RuntimeError("Компонент центра IGProxy не установлен") + hub_env = os.environ.copy() + hub_env["IGPROXY_HUB_STATE"] = "/opt/gotelegram" + hub_env["GOTELEGRAM_CONFIG"] = str(GOTELEGRAM_CONFIG) result = subprocess.run( [sys.executable, str(IGPROXY_HUB_CLI), "create-code", "--ttl", "1800"], check=False, capture_output=True, text=True, timeout=8, + env=hub_env, ) if result.returncode != 0: raise RuntimeError("Центр не смог создать код подключения") diff --git a/install.sh b/install.sh index 098b34f..6e88271 100644 --- a/install.sh +++ b/install.sh @@ -980,9 +980,6 @@ install_lite_mode() { } [ "$INSTALLER_DEFER_COMMIT" = "1" ] || installer_transaction_commit - # Credits - show_credits - # Result show_proxy_info log_success "$(tf install_done "$GOTELEGRAM_VERSION" "Только прокси")" diff --git a/lib/cluster.sh b/lib/cluster.sh index 792e148..dc9d9b1 100644 --- a/lib/cluster.sh +++ b/lib/cluster.sh @@ -109,6 +109,8 @@ LockPersonality=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 Environment=IGPROXY_HUB_HOST=127.0.0.1 Environment=IGPROXY_HUB_PORT=$IGPROXY_HUB_PORT +Environment=IGPROXY_HUB_STATE=/opt/gotelegram +Environment=GOTELEGRAM_CONFIG=/opt/gotelegram/config.json [Install] WantedBy=multi-user.target @@ -207,14 +209,21 @@ cluster_install_node_service() { esac hub_url="${hub_url%/}" label=$(printf '%s' "$label" | tr '\r\n\t' ' ' | sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//' | cut -c1-48) + pairing_code=$(printf '%s' "$pairing_code" | tr -d '[:space:]') [ -n "$label" ] || { log_error "Название узла не может быть пустым." return 1 } [ -n "$pairing_code" ] || { log_error "Не указан одноразовый код подключения." - return 1 + return 10 + } + if ! printf '%s' "$pairing_code" | grep -Eq '^[A-Za-z0-9_-]{32,128}$'; then + log_error "Код подключения имеет неверный формат." + log_dim "Скопируйте только сам код из основного IGProxy, без подписи и кавычек." + return 10 } + log_dim "Код распознан: ${#pairing_code} символа." public_ip=$(get_server_ip 2>/dev/null || true) install -d -m 700 "$IGPROXY_NODE_DIR" "$IGPROXY_NODE_STATE_DIR" install -m 700 "$source" "$IGPROXY_NODE_DIR/node_agent.py" @@ -284,26 +293,35 @@ Environment=IGPROXY_NODE_CONFIG=$IGPROXY_NODE_STATE_DIR/node.json WantedBy=multi-user.target EOF systemctl daemon-reload - systemctl enable --now "$IGPROXY_NODE_SERVICE" >/dev/null 2>&1 - local waited=0 - while [ "$waited" -lt 30 ]; do - if jq -e '.node_token and (.node_token | length > 20)' \ - "$IGPROXY_NODE_STATE_DIR/node.json" >/dev/null 2>&1; then - log_success "Узел зарегистрирован в едином центре." - log_dim "Регистрация выполнена исходящим HTTPS-запросом; входящий служебный порт не открывался." - return 0 - fi - systemctl is-active --quiet "$IGPROXY_NODE_SERVICE" || break - sleep 2 - waited=$((waited + 2)) - done - log_error "Агент установлен, но центр не подтвердил регистрацию." - journalctl -u "$IGPROXY_NODE_SERVICE" -n 8 --no-pager 2>/dev/null || true - return 1 + + local enroll_output enroll_rc + if enroll_output=$(IGPROXY_NODE_CONFIG="$IGPROXY_NODE_STATE_DIR/node.json" \ + "$python_bin" "$IGPROXY_NODE_DIR/node_agent.py" --once 2>&1); then + enroll_rc=0 + else + enroll_rc=$? + fi + if [ "$enroll_rc" -eq 10 ]; then + log_error "Центр отклонил одноразовый код: он неверный, просрочен или уже использован." + log_dim "Создайте новый код в основном IGProxy и повторите ввод." + return 10 + fi + if [ "$enroll_rc" -ne 0 ]; then + log_error "Не удалось связаться с центром: ${enroll_output:-неизвестная ошибка}" + return 1 + fi + + systemctl enable --now "$IGPROXY_NODE_SERVICE" >/dev/null 2>&1 || { + log_error "Узел зарегистрирован, но служба синхронизации не запустилась." + return 1 + } + log_success "Узел зарегистрирован в едином центре." + log_dim "Регистрация выполнена исходящим HTTPS-запросом; входящий служебный порт не открывался." + return 0 } cluster_configure_node_interactive() { - local hub_url label pairing_code default_label + local hub_url label pairing_code default_label result default_label=$(hostname 2>/dev/null || echo "Новый сервер") if type ig_ui_heading >/dev/null 2>&1; then ig_ui_heading "ШАГ 4 · ЕДИНАЯ СЕТЬ" "Подключение к центру" \ @@ -311,8 +329,17 @@ cluster_configure_node_interactive() { fi hub_url=$(cluster_prompt_line "HTTPS-адрес центра" "") label=$(cluster_prompt_line "Название кнопки сервера" "$default_label") - pairing_code=$(cluster_prompt_secret "Одноразовый код") - cluster_install_node_service "$hub_url" "$label" "$pairing_code" + while true; do + pairing_code=$(cluster_prompt_secret "Одноразовый код (0 — назад)") + [ "$pairing_code" = "0" ] && return 1 + if cluster_install_node_service "$hub_url" "$label" "$pairing_code"; then + return 0 + else + result=$? + fi + [ "$result" -eq 10 ] || return "$result" + echo "" + done } cluster_finalize_deployment_role() { diff --git a/lib/common.sh b/lib/common.sh index e8561ec..6481684 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -3,7 +3,7 @@ # Colors, logging, spinner, system helpers, v1 compat, i18n-aware # ── Version ─────────────────────────────────────────────────────────────────── -GOTELEGRAM_VERSION="2.15.4" +GOTELEGRAM_VERSION="2.15.5" GOTELEGRAM_NAME="IGProxy" # ── Пути ────────────────────────────────────────────────────────────────────── @@ -163,29 +163,6 @@ show_banner() { echo "" } -# ── Credits ────────────────────────────────────────────────────────────────── -show_credits() { - local line - line=$(printf '─%.0s' $(seq 1 60)) - echo "" - echo -e "${MAGENTA}${line}${NC}" - echo -e " ${BOLD}$(type t &>/dev/null && t credits_title || echo 'Credits')${NC}" - echo -e "${MAGENTA}${line}${NC}" - echo -e " ${WHITE}telemt${NC} — MTProxy engine (Rust)" - echo -e " ${DIM}github.com/telemt/telemt${NC}" - echo "" - echo -e " ${WHITE}HTML5 UP${NC} — responsive HTML/CSS templates" - echo -e " ${DIM}html5up.net • CC BY 3.0 • @ajlkn${NC}" - echo "" - echo -e " ${WHITE}learning-zone${NC} — 150+ HTML5 templates" - echo -e " ${DIM}github.com/learning-zone/website-templates${NC}" - echo "" - echo -e " ${WHITE}Start Bootstrap${NC} — MIT license" - echo -e " ${DIM}startbootstrap.com${NC}" - echo -e "${MAGENTA}${line}${NC}" - echo "" -} - # ── Системные утилиты ──────────────────────────────────────────────────────── _valid_ip() { # Validate that each octet is 0-255 diff --git a/lib/website.sh b/lib/website.sh index 33fc637..8a260d8 100644 --- a/lib/website.sh +++ b/lib/website.sh @@ -620,9 +620,6 @@ setup_pro_mode() { setup_ssl_auto_renewal fi - # 8. Показываем благодарности авторам шаблонов - show_credits - log_success "Сайт настроен: $(format_https_url "$domain" "$public_port")" return 0 } diff --git a/tests/test_bot_features.py b/tests/test_bot_features.py index a2c9ef4..538ded3 100644 --- a/tests/test_bot_features.py +++ b/tests/test_bot_features.py @@ -136,6 +136,8 @@ def test_hub_admin_can_create_short_lived_pairing_code(self): self.assertIn('"cluster_pairing_code": cb_cluster_pairing_code', source) self.assertIn("def create_cluster_pairing_code()", source) self.assertIn('"create-code", "--ttl", "1800"', source) + self.assertIn('hub_env["IGPROXY_HUB_STATE"] = "/opt/gotelegram"', source) + self.assertIn("env=hub_env", source) self.assertIn("Код действует 30 минут", source) self.assertIn("await asyncio.to_thread(create_cluster_pairing_code)", source) self.assertNotIn("shell=True", source) diff --git a/tests/test_cluster_features.py b/tests/test_cluster_features.py index e970ad4..46aef63 100644 --- a/tests/test_cluster_features.py +++ b/tests/test_cluster_features.py @@ -1,9 +1,12 @@ import importlib.util +import io import json import os import sys import tempfile import unittest +import urllib.error +from unittest import mock from pathlib import Path @@ -80,6 +83,19 @@ def test_one_time_enrollment_syncs_managed_server_without_raw_secrets(self): } ) + def test_enrollment_ignores_whitespace_added_while_copying_code(self): + code, _ = self.hub.create_enrollment_code(600) + decorated_code = f" \n{code[:12]}\t{code[12:]}\r\n" + result = self.hub.enroll_node( + { + "pairing_code": decorated_code, + "install_id": "copied-code", + "label": "Литва", + "proxy_url": "https://t.me/proxy?server=node.example&port=8443&secret=abcdef", + } + ) + self.assertTrue(result["node_token"]) + def test_heartbeat_requires_token_and_updates_public_status(self): code, _ = self.hub.create_enrollment_code(600) proxy_url = "https://t.me/proxy?server=198.51.100.7&port=9443&secret=abcdef" @@ -115,12 +131,43 @@ def test_node_agent_refuses_all_http_redirects(self): handler.redirect_request(None, None, 302, "Found", {}, "https://evil.example/") ) + def test_node_agent_exposes_hub_error_without_leaking_request(self): + spec = importlib.util.spec_from_file_location( + "igproxy_node_http_error_test", ROOT / "cluster" / "node_agent.py" + ) + node = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(node) + response = io.BytesIO( + b'{"ok":false,"error":"pairing code is invalid or expired"}' + ) + error = urllib.error.HTTPError( + "https://hub.example/v1/enroll", 401, "Unauthorized", {}, response + ) + with mock.patch.object(node.HTTP_OPENER, "open", side_effect=error): + with self.assertRaises(node.HubRequestError) as raised: + node.request( + {"hub_url": "https://hub.example"}, + "v1/enroll", + {"pairing_code": "not-logged"}, + ) + self.assertEqual(raised.exception.status, 401) + self.assertEqual(str(raised.exception), "pairing code is invalid or expired") + def test_hub_installer_waits_for_loopback_health(self): source = (ROOT / "lib" / "cluster.sh").read_text(encoding="utf-8") self.assertIn('while [ "$waited" -lt 10 ]; do', source) self.assertIn('hub_ready=1', source) self.assertIn('systemctl is-active --quiet "$IGPROXY_HUB_SERVICE" || break', source) self.assertIn('if [ "$hub_ready" != "1" ]; then', source) + self.assertIn("Environment=IGPROXY_HUB_STATE=/opt/gotelegram", source) + + def test_node_installer_checks_pairing_before_starting_service(self): + source = (ROOT / "lib" / "cluster.sh").read_text(encoding="utf-8") + self.assertIn('"$IGPROXY_NODE_DIR/node_agent.py" --once', source) + self.assertIn('return 10', source) + self.assertIn('while true; do', source) + self.assertIn("Центр отклонил одноразовый код", source) if __name__ == "__main__": diff --git a/tests/test_installer_wizard.py b/tests/test_installer_wizard.py index 6e13b42..6c8423e 100644 --- a/tests/test_installer_wizard.py +++ b/tests/test_installer_wizard.py @@ -319,6 +319,14 @@ def test_hub_public_api_has_abuse_limits(self): self.assertIn("RuntimeDirectory=gotelegram", cluster) self.assertIn("ReadWritePaths=/opt/gotelegram /run/gotelegram", cluster) + def test_installer_does_not_interrupt_installation_with_credits_screen(self): + install = INSTALL.read_text(encoding="utf-8") + website = (ROOT / "lib" / "website.sh").read_text(encoding="utf-8") + common = (ROOT / "lib" / "common.sh").read_text(encoding="utf-8") + self.assertNotIn("show_credits", install) + self.assertNotIn("show_credits", website) + self.assertNotIn("show_credits()", common) + def test_release_contains_cluster_components(self): release = (ROOT / "tools" / "build_release.py").read_text(encoding="utf-8") self.assertIn('"cluster"', release)