Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions DEPLOYMENT_PROFILES.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
Центр выдаёт одноразовый код на 30 минут. На узле вводятся HTTPS-адрес центра,
название клиентской кнопки и этот код. После первого обмена код удаляется,
а дальнейшие heartbeat-запросы подписываются отдельным длинным токеном узла.
Код создаётся либо в CLI центра, либо кнопкой `Код подключения узла` в
Telegram-боте; кнопка доступна только администраторам.
Служебный процесс центра слушает только `127.0.0.1:1990`; наружу публикуется
лишь путь `/__igproxy/` через HTTPS-сайт центра. На дополнительном узле
служебный входящий порт вообще не открывается.
Expand Down
5 changes: 5 additions & 0 deletions INSTALLER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. Витрина сайта

Expand Down
4 changes: 2 additions & 2 deletions admin-web/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
93 changes: 91 additions & 2 deletions gotelegram-bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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"<b>Не удалось создать код</b>\n\n{html.escape(str(exc))}",
reply_markup=InlineKeyboardMarkup([[
InlineKeyboardButton("‹ К серверам", callback_data="menu_client_servers"),
]]),
parse_mode="HTML",
)
return

await safe_edit_message(
query,
"<b>🔗 Подключение нового узла</b>\n\n"
f"Адрес центра:\n<code>{html.escape(payload['hub_url'])}</code>\n\n"
f"Одноразовый код:\n<code>{html.escape(payload['code'])}</code>\n\n"
"Код действует <b>30 минут</b> и используется только один раз. "
"На новом 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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"

# ── Пути ──────────────────────────────────────────────────────────────────────
Expand Down
81 changes: 76 additions & 5 deletions lib/website.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/test_bot_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("Код действует <b>30 минут</b>", 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)
Expand Down
10 changes: 10 additions & 0 deletions tests/test_installer_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading