From 7b2bd606432ddcc1118570fadbb5406324490510 Mon Sep 17 00:00:00 2001 From: IG-SSP Date: Wed, 29 Jul 2026 02:44:56 +0300 Subject: [PATCH] feat: issue node codes in bot and harden ACME --- DEPLOYMENT_PROFILES.md | 2 + INSTALLER_GUIDE.md | 5 ++ admin-web/server.py | 4 +- gotelegram-bot/bot.py | 93 +++++++++++++++++++++++++++++++++- lib/common.sh | 2 +- lib/website.sh | 81 +++++++++++++++++++++++++++-- tests/test_bot_features.py | 10 ++++ tests/test_installer_wizard.py | 10 ++++ 8 files changed, 197 insertions(+), 10 deletions(-) diff --git a/DEPLOYMENT_PROFILES.md b/DEPLOYMENT_PROFILES.md index 89a6e3e..03a2940 100644 --- a/DEPLOYMENT_PROFILES.md +++ b/DEPLOYMENT_PROFILES.md @@ -14,6 +14,8 @@ Центр выдаёт одноразовый код на 30 минут. На узле вводятся HTTPS-адрес центра, название клиентской кнопки и этот код. После первого обмена код удаляется, а дальнейшие heartbeat-запросы подписываются отдельным длинным токеном узла. +Код создаётся либо в CLI центра, либо кнопкой `Код подключения узла` в +Telegram-боте; кнопка доступна только администраторам. Служебный процесс центра слушает только `127.0.0.1:1990`; наружу публикуется лишь путь `/__igproxy/` через HTTPS-сайт центра. На дополнительном узле служебный входящий порт вообще не открывается. diff --git a/INSTALLER_GUIDE.md b/INSTALLER_GUIDE.md index 68420e9..fe9c961 100644 --- a/INSTALLER_GUIDE.md +++ b/INSTALLER_GUIDE.md @@ -61,6 +61,11 @@ UDP-порты, firewall, Hysteria, Xray/3x-ui, nginx и обнаруженну конфигурацию и не устанавливает собственный обработчик продления сертификата. Если сертификат отсутствует или истекает менее чем через сутки, мастер запускает обычную проверку Let's Encrypt. +Перед обращением к Let's Encrypt мастер публикует тестовый challenge и +проверяет его локально и через публичный TCP/80. Если URL перехватывает другой +nginx-vhost, после отдельного подтверждения доступен standalone challenge: +nginx останавливается примерно на 10–30 секунд и обязательно запускается +снова. Для этой линии сохраняются pre/post hooks последующего продления. ### 4. Витрина сайта diff --git a/admin-web/server.py b/admin-web/server.py index 676b358..c9e8037 100644 --- a/admin-web/server.py +++ b/admin-web/server.py @@ -66,7 +66,7 @@ SITE_PRESETS_DIR = Path(os.getenv("GOTELEGRAM_SITE_PRESETS", "/opt/gotelegram/current/site-presets")) HOST = os.getenv("GOTELEGRAM_ADMIN_HOST", "127.0.0.1") PORT = int(os.getenv("GOTELEGRAM_ADMIN_PORT", "1984")) -VERSION = "2.15.3" # fallback only; live value read from config.json +VERSION = "2.15.4" # fallback only; live value read from config.json RUNTIME_COMMON_PATHS = ( INSTALL_DIR / "current" / "lib" / "common.sh", Path(__file__).resolve().parents[1] / "lib" / "common.sh", @@ -1533,7 +1533,7 @@ def health_payload(force: bool = False) -> dict[str, Any]: issues.append({ "level": "warn", "title": f"Установлен telemt {version}", - "detail": "Для IGProxy 2.15.3 проверена версия 3.4.25.", + "detail": "Для IGProxy 2.15.4 проверена версия 3.4.25.", "action": "Обновите ядро с резервной копией бинарника и конфига.", }) if handshake_mss and not bulk_mss: diff --git a/gotelegram-bot/bot.py b/gotelegram-bot/bot.py index 56e2ca9..3b97f89 100644 --- a/gotelegram-bot/bot.py +++ b/gotelegram-bot/bot.py @@ -110,7 +110,7 @@ def _read_gotelegram_version() -> str: return str(_v) except Exception: pass - return "2.15.3" + return "2.15.4" GOTELEGRAM_VERSION = _read_gotelegram_version() @@ -130,6 +130,7 @@ def _read_gotelegram_version() -> str: ADMIN_WEB_SERVICE = "gotelegram-admin" ADMIN_WEB_PORT = 1984 ADMIN_WEB_GUIDE = _BOT_DIR / "assets" / "termius-port-forwarding.png" +IGPROXY_HUB_CLI = Path("/opt/igproxy-hub/hub_server.py") def format_bytes_human(value: int) -> str: @@ -770,7 +771,13 @@ def admin_client_servers_keyboard() -> InlineKeyboardMarkup: )] for item in get_client_servers() ] - rows.append([InlineKeyboardButton("➕ Добавить сервер", callback_data="client_server_add")]) + config = load_json(GOTELEGRAM_CONFIG) or {} + if config.get("deployment_role") in {"hub", "controller"}: + rows.append([InlineKeyboardButton( + "🔗 Код подключения узла", + callback_data="cluster_pairing_code", + )]) + rows.append([InlineKeyboardButton("➕ Добавить сервер вручную", callback_data="client_server_add")]) rows.append([InlineKeyboardButton("‹ Назад", callback_data="menu_main")]) return InlineKeyboardMarkup(rows) @@ -793,6 +800,87 @@ async def cb_client_servers(update: Update, context: ContextTypes.DEFAULT_TYPE) ) +def create_cluster_pairing_code() -> Dict[str, Any]: + """Create a short-lived Hub enrollment code without invoking a shell.""" + config = load_json(GOTELEGRAM_CONFIG) or {} + if config.get("deployment_role") not in {"hub", "controller"}: + raise RuntimeError("Этот сервер не является центром IGProxy") + if not IGPROXY_HUB_CLI.is_file(): + raise RuntimeError("Компонент центра IGProxy не установлен") + + result = subprocess.run( + [sys.executable, str(IGPROXY_HUB_CLI), "create-code", "--ttl", "1800"], + check=False, + capture_output=True, + text=True, + timeout=8, + ) + if result.returncode != 0: + raise RuntimeError("Центр не смог создать код подключения") + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + raise RuntimeError("Центр вернул некорректный ответ") + code = str(payload.get("code") or "") + expires_at = int(payload.get("expires_at") or 0) + if not re.fullmatch(r"[A-Za-z0-9_-]{32,128}", code) or expires_at <= int(time.time()): + raise RuntimeError("Центр вернул некорректный код подключения") + + domain = str(config.get("domain") or "").strip() + port = int(config.get("port") or 443) + if not domain or not 1 <= port <= 65535: + raise RuntimeError("В центре не настроен публичный домен") + authority = domain if port == 443 else f"{domain}:{port}" + return { + "code": code, + "expires_at": expires_at, + "hub_url": f"https://{authority}/__igproxy", + } + + +async def cb_cluster_pairing_code( + update: Update, + context: ContextTypes.DEFAULT_TYPE, +) -> None: + query = update.callback_query + await query.answer("Создаю одноразовый код…") + try: + payload = await asyncio.to_thread(create_cluster_pairing_code) + except ( + RuntimeError, + ValueError, + OSError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + ) as exc: + logger.warning("Unable to create cluster pairing code: %s", type(exc).__name__) + await safe_edit_message( + query, + f"Не удалось создать код\n\n{html.escape(str(exc))}", + reply_markup=InlineKeyboardMarkup([[ + InlineKeyboardButton("‹ К серверам", callback_data="menu_client_servers"), + ]]), + parse_mode="HTML", + ) + return + + await safe_edit_message( + query, + "🔗 Подключение нового узла\n\n" + f"Адрес центра:\n{html.escape(payload['hub_url'])}\n\n" + f"Одноразовый код:\n{html.escape(payload['code'])}\n\n" + "Код действует 30 минут и используется только один раз. " + "На новом VPS выберите роль «Дополнительный прокси-узел».", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton( + "Создать новый код", + callback_data="cluster_pairing_code", + )], + [InlineKeyboardButton("Скрыть код", callback_data="menu_client_servers")], + ]), + parse_mode="HTML", + ) + + async def cb_client_view(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Show an administrator the exact client catalogue with a return button.""" query = update.callback_query @@ -3490,6 +3578,7 @@ async def handle_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> "menu_admins": cb_menu_admins, "menu_users": cb_menu_users, "menu_client_servers": cb_client_servers, + "cluster_pairing_code": cb_cluster_pairing_code, "menu_client_view": cb_client_view, "client_server_add": cb_client_server_add, "backup_create": cb_backup_create, diff --git a/lib/common.sh b/lib/common.sh index f70bc51..e8561ec 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.3" +GOTELEGRAM_VERSION="2.15.4" GOTELEGRAM_NAME="IGProxy" # ── Пути ────────────────────────────────────────────────────────────────────── diff --git a/lib/website.sh b/lib/website.sh index 05b3c96..33fc637 100644 --- a/lib/website.sh +++ b/lib/website.sh @@ -293,10 +293,42 @@ igproxy_certificate_lineage_name() { printf 'igproxy-%s\n' "$digest" } +acme_webroot_probe() { + local domain="$1" token expected challenge_dir challenge_file uri local_body public_body + token=$(openssl rand -hex 12 2>/dev/null) || return 1 + expected="igproxy-acme-probe-${token}" + challenge_dir="/var/www/certbot/.well-known/acme-challenge" + challenge_file="$challenge_dir/$token" + uri="/.well-known/acme-challenge/$token" + mkdir -p "$challenge_dir" || return 1 + printf '%s' "$expected" > "$challenge_file" || return 1 + chmod 644 "$challenge_file" + + local_body=$(curl -4fsS --noproxy '*' --max-time 5 \ + --resolve "${domain}:80:127.0.0.1" "http://${domain}${uri}" 2>/dev/null || true) + if [ "$local_body" != "$expected" ]; then + rm -f -- "$challenge_file" + log_warning "Временный nginx не отдаёт ACME-файл для домена $domain." + log_dim "Обычно это означает другой server_name на TCP/80 или существующую конфигурацию 3x-ui/nginx." + return 10 + fi + + public_body=$(curl -4fsS --noproxy '*' --max-time 8 \ + "http://${domain}${uri}" 2>/dev/null || true) + rm -f -- "$challenge_file" + if [ "$public_body" != "$expected" ]; then + log_error "ACME-файл доступен локально, но недоступен через публичный порт 80." + log_dim "Проверьте firewall, проброс порта и внешний reverse proxy. Запрос в Let's Encrypt не отправлялся." + return 11 + fi + log_success "Публичная проверка ACME challenge пройдена" +} + obtain_ssl_certificate() { local domain="$1" local email="${2:-}" local cert_live_dir="" dedicated_name="" dedicated_dir="" + local challenge_mode="webroot" nginx_stopped=0 probe_status=0 IGPROXY_CERT_WAS_REUSED=0 if ! cert_live_dir=$(ssl_certificate_live_dir "$domain"); then @@ -313,10 +345,36 @@ obtain_ssl_certificate() { return 1 } - local certbot_args=( - certonly - --webroot - -w /var/www/certbot + acme_webroot_probe "$domain" || probe_status=$? + case "$probe_status" in + 0) ;; + 10) + log_warning "Webroot-конфликт можно обойти одноразовым standalone challenge." + log_dim "Для этого nginx будет остановлен примерно на 10–30 секунд; его файлы не изменяются." + if confirm "Временно остановить nginx только на время выпуска сертификата?"; then + challenge_mode="standalone" + else + log_error "Выпуск сертификата отменён до обращения в Let's Encrypt." + return 1 + fi + ;; + *) return 1 ;; + esac + + local certbot_args=(certonly) + if [ "$challenge_mode" = "webroot" ]; then + certbot_args+=(--webroot -w /var/www/certbot) + else + # Хуки сохраняются в renewal-конфигурации этой линии: при будущем + # renew standalone снова освободит TCP/80 и обязательно вернёт nginx. + certbot_args+=( + --standalone + --preferred-challenges http + --pre-hook "systemctl stop nginx" + --post-hook "systemctl start nginx" + ) + fi + certbot_args+=( -d "$domain" --non-interactive --agree-tos @@ -340,7 +398,20 @@ obtain_ssl_certificate() { local certbot_log certbot_log=$(mktemp /tmp/gotelegram-certbot.XXXXXX) || return 1 chmod 600 "$certbot_log" - if certbot "${certbot_args[@]}" >"$certbot_log" 2>&1; then + [ "$challenge_mode" != "standalone" ] || nginx_stopped=1 + + local certbot_ok=0 + certbot "${certbot_args[@]}" >"$certbot_log" 2>&1 && certbot_ok=1 + if [ "$nginx_stopped" = "1" ]; then + if ! systemctl start nginx; then + log_error "После ACME challenge не удалось снова запустить nginx." + tail -n 8 "$certbot_log" | sed 's/^/ /' >&2 + rm -f -- "$certbot_log" + return 1 + fi + fi + + if [ "$certbot_ok" = "1" ]; then rm -f -- "$certbot_log" cert_live_dir=$(ssl_certificate_live_dir "$domain" 2>/dev/null || true) if [ -n "$cert_live_dir" ]; then diff --git a/tests/test_bot_features.py b/tests/test_bot_features.py index 9ee217c..a2c9ef4 100644 --- a/tests/test_bot_features.py +++ b/tests/test_bot_features.py @@ -130,6 +130,16 @@ def test_admin_can_preview_client_server_menu_and_go_back(self): self.assertIn('callback_data="menu_main"', source) self.assertIn("‹ Назад в Управление", source) + def test_hub_admin_can_create_short_lived_pairing_code(self): + source = BOT_PATH.read_text(encoding="utf-8") + self.assertIn('callback_data="cluster_pairing_code"', source) + 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("Код действует 30 минут", source) + self.assertIn("await asyncio.to_thread(create_cluster_pairing_code)", source) + self.assertNotIn("shell=True", source) + def test_brand_and_sponsor_are_loaded_from_shared_config(self): source = BOT_PATH.read_text(encoding="utf-8") self.assertIn('config.get("brand_enabled", True)', source) diff --git a/tests/test_installer_wizard.py b/tests/test_installer_wizard.py index d34d50a..6e13b42 100644 --- a/tests/test_installer_wizard.py +++ b/tests/test_installer_wizard.py @@ -382,6 +382,16 @@ def test_certificate_renewal_does_not_modify_root_crontab(self): self.assertIn('systemctl restart nginx || {', website) self.assertIn('INSTALLER_TX_CERT_NAME="${INSTALLER_DOMAIN_CERT_LINEAGE:-}"', INSTALL.read_text(encoding="utf-8")) self.assertIn('installer_firewall_check_ports "$public_port" || return', INSTALL.read_text(encoding="utf-8")) + self.assertIn('acme_webroot_probe "$domain" || probe_status=$?', website) + self.assertLess( + website.index('acme_webroot_probe "$domain"'), + website.index('certbot "${certbot_args[@]}"'), + ) + self.assertIn("Запрос в Let's Encrypt не отправлялся.", website) + self.assertIn('if confirm "Временно остановить nginx', website) + self.assertIn('--pre-hook "systemctl stop nginx"', website) + self.assertIn('--post-hook "systemctl start nginx"', website) + self.assertIn('systemctl start nginx', website) def test_new_installer_uses_only_bundled_igproxy_site_presets(self): install = INSTALL.read_text(encoding="utf-8")