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
4 changes: 3 additions & 1 deletion cluster/hub_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
89 changes: 69 additions & 20 deletions cluster/node_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"))
Expand Down Expand Up @@ -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 {}


Expand All @@ -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(),
Expand All @@ -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:
Expand All @@ -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())
4 changes: 4 additions & 0 deletions gotelegram-bot/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("Центр не смог создать код подключения")
Expand Down
3 changes: 0 additions & 3 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" "Только прокси")"
Expand Down
67 changes: 47 additions & 20 deletions lib/cluster.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -284,35 +293,53 @@ 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 · ЕДИНАЯ СЕТЬ" "Подключение к центру" \
"Возьмите адрес и одноразовый код в основном IGProxy."
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() {
Expand Down
25 changes: 1 addition & 24 deletions 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.4"
GOTELEGRAM_VERSION="2.15.5"
GOTELEGRAM_NAME="IGProxy"

# ── Пути ──────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down
3 changes: 0 additions & 3 deletions lib/website.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions tests/test_bot_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("Код действует <b>30 минут</b>", source)
self.assertIn("await asyncio.to_thread(create_cluster_pairing_code)", source)
self.assertNotIn("shell=True", source)
Expand Down
Loading
Loading