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")