mirror of
https://github.com/Trollobot/Telemax.git
synced 2026-10-19 10:23:24 +00:00
697 lines
41 KiB
Bash
Executable file
697 lines
41 KiB
Bash
Executable file
#!/usr/bin/env bash
|
||
# One-command bootstrap for a bare Ubuntu/Debian server: system update, git,
|
||
# Docker, clone the repo, hand off to setup.sh for the interactive key setup.
|
||
#
|
||
# Usage (as root, on a fresh server):
|
||
# curl -fsSL https://raw.githubusercontent.com/Trollobot/Telemax/main/install.sh | bash
|
||
#
|
||
# Safe to re-run: skips steps that are already done (git/Docker already
|
||
# installed, repo already cloned — updates it to the latest SIGNED release instead).
|
||
set -euo pipefail
|
||
|
||
REPO_URL="https://github.com/Trollobot/Telemax.git"
|
||
# Read-only fallback mirror for when GitHub is unreachable (account flagged, or GitHub filtered on
|
||
# this network). Overridable via env so the mirror can move without editing this script.
|
||
MIRROR_GIT_URL="${MIRROR_GIT_URL:-https://zergont-gate.duckdns.org/Telemax.git}"
|
||
# Overridable so a SECOND, independent bridge can live on the same host (different bot, different
|
||
# group, different MAX account): curl -fsSL <...>/install.sh | INSTALL_DIR=/opt/telemax-2 bash
|
||
# An EXPLICIT dir means the caller already decided what to do — the existing-bridges menu below
|
||
# stays out of the way (keeps the documented one-liner and any automation non-interactive).
|
||
if [ -n "${INSTALL_DIR:-}" ]; then INSTALL_DIR_EXPLICIT=1; else INSTALL_DIR_EXPLICIT=0; INSTALL_DIR=/opt/telemax; fi
|
||
# The maintainer's release-signing key, pinned HERE as well as in the repo's allowed_signers:
|
||
# update.sh trusts whatever allowed_signers the checkout carries, so a tampered clone (a
|
||
# compromised transport swapping in an attacker's key) would otherwise bootstrap a poisoned
|
||
# trust chain. It is the trust anchor for the CODE too: a fresh clone and an updated checkout are
|
||
# both moved only onto a release tag whose signature verifies against this key (see
|
||
# newest_signed_release), and the allowed_signers check further down only confirms the checkout
|
||
# carries the same key — comparing a public key file proves nothing about the code next to it. As
|
||
# long as THIS script arrived over HTTPS, a tampered source is refused.
|
||
EXPECTED_SIGNER='release@telemax namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILE1HMFVabDZUp6fRrnnt2lMgTY57ghrMP1pp+dE5MIe'
|
||
|
||
bold() { printf '\033[1m%s\033[0m\n' "$1"; }
|
||
|
||
if [ "$(id -u)" -ne 0 ]; then
|
||
echo "Запустите от root (на большинстве VPS вы и так root по SSH; иначе — sudo -i, затем повторите команду)."
|
||
exit 1
|
||
fi
|
||
|
||
export DEBIAN_FRONTEND=noninteractive
|
||
# dpkg's conffile prompt ("cloud.cfg modified — Y/I/N/O/D/Z?") is NOT covered by DEBIAN_FRONTEND:
|
||
# that only silences debconf. With our stdin guard (</dev/null below) such a prompt hits EOF, the
|
||
# package is left half-configured, dpkg is broken for every later apt call — and get.docker.com,
|
||
# which runs its OWN apt-get, then fails too (hit live 2026-09-10 on a fresh Ubuntu 24.04 VPS whose
|
||
# provider had edited /etc/cloud/cloud.cfg). Fix: for the duration of this install, tell dpkg
|
||
# GLOBALLY to keep the existing config on a conflict (confold) and take the package default where
|
||
# there is none (confdef) — via apt.conf.d, so it also reaches apt-get calls we don't make
|
||
# ourselves (Docker's installer). Removed on exit, so the server's normal apt behaviour is untouched.
|
||
export UCF_FORCE_CONFOLD=1
|
||
APT_NI_SNIPPET=/etc/apt/apt.conf.d/99telemax-install
|
||
printf 'Dpkg::Options { "--force-confdef"; "--force-confold"; };\n' > "$APT_NI_SNIPPET"
|
||
# The pinned key in the form `git verify-tag` reads (gpg.ssh.allowedSignersFile).
|
||
SIGNERS=$(mktemp)
|
||
printf '%s\n' "$EXPECTED_SIGNER" > "$SIGNERS"
|
||
trap 'rm -f "$APT_NI_SNIPPET" "$SIGNERS"' EXIT
|
||
# Same two flags for direct dpkg calls (unquoted on purpose — it must split into two arguments).
|
||
DPKG_NI="--force-confdef --force-confold"
|
||
|
||
# Best-effort apt wrapper. Some VPS images ship with an unrelated package
|
||
# already broken (seen live — initramfs-tools' dhcpcd hook failing on a
|
||
# missing .so with nothing to do with Telemax) whose dpkg trigger re-fires
|
||
# and fails on EVERY subsequent apt-get call, not just the one that first
|
||
# surfaced it — upgrade, then installing jq, then (if left unguarded) the
|
||
# Docker install too. The package we actually asked for still installs fine
|
||
# each time; only that unrelated trigger's own exit code is non-zero.
|
||
# Aborting the whole bootstrap over someone else's package is worse than
|
||
# warning once and moving on, so every apt-get call in this script goes
|
||
# through this instead of a bare one.
|
||
# `</dev/null` on every apt/dpkg call is critical under `curl | bash`: bash reads THIS script from
|
||
# stdin (the pipe), and a package maintainer script that reads stdin during `apt upgrade` (grub,
|
||
# cloud-init, …) would otherwise consume the rest of the script and the install silently stops after
|
||
# that step (hit live on a fresh Ubuntu 24.04 box). Giving apt its own empty stdin keeps the pipe
|
||
# — and the rest of this script — intact for bash.
|
||
apt_get() {
|
||
# A fresh VPS often still runs unattended-upgrades for minutes after boot, holding the apt/dpkg
|
||
# locks — "Could not get lock" used to fail the very first step. Wait for it (bounded), and let
|
||
# apt itself wait on the dpkg lock too.
|
||
local waited=0
|
||
while pgrep -x apt-get >/dev/null 2>&1 || pgrep -x dpkg >/dev/null 2>&1 || pgrep -f unattended-upgrade >/dev/null 2>&1; do
|
||
[ "$waited" -eq 0 ] && echo " ⏳ Жду, пока система закончит фоновое обновление (unattended-upgrades)..."
|
||
sleep 5; waited=$((waited + 5)); [ "$waited" -ge 600 ] && break
|
||
done
|
||
if ! apt-get -o DPkg::Lock::Timeout=300 "$@" </dev/null; then
|
||
echo "⚠️ apt-get $* завершился с предупреждением (см. вывод выше) — похоже, дело в стороннем пакете, не связанном с Telemax. Продолжаю; если хотите разобраться отдельно, обычно помогает: dpkg --configure -a"
|
||
# With the force flags, a pending configure that was waiting on a conffile prompt actually
|
||
# completes here instead of dying on the same question (which is what used to happen).
|
||
dpkg --configure -a $DPKG_NI >/dev/null 2>&1 </dev/null || true
|
||
fi
|
||
}
|
||
|
||
# Release tags are verified with SSH signatures (git -c gpg.format=ssh verify-tag), which git only
|
||
# understands since 2.34. An older git (Debian 11: 2.30, Ubuntu 20.04: 2.25) failed every tag, and a
|
||
# fresh install stopped with «возможна подмена источника» — wrong, and no retry could ever help
|
||
# (review 2026-09-26, shell-r2#1). True when the installed git is new enough (same check as update.sh).
|
||
git_verifies_ssh_signatures() { printf '2.34\n%s\n' "$(git --version 2>/dev/null | awk '{print $3}')" | sort -C -V; }
|
||
# Prints why this host cannot verify a release signature at all (nokeygen | oldgit); nothing when it can.
|
||
verify_blocker() {
|
||
command -v ssh-keygen >/dev/null 2>&1 || { echo nokeygen; return 0; }
|
||
git_verifies_ssh_signatures || echo oldgit
|
||
}
|
||
|
||
# Why the checkout could not be moved to a release, with the fix: one reason per failed attempt
|
||
# (GitHub, then the mirror) accumulates in UPDATE_FAIL_REASONS; a fresh install that cannot verify
|
||
# a signature at all passes its blocker as $1. «Переустановка начисто» is NOT advice here: it keeps
|
||
# the git checkout (only .env and data are wiped), so the same update fails again with the same
|
||
# message — an endless loop (review 2026-09-26, shell-r2#0). What does help: removing the bridge
|
||
# (the menu backs it up first) and installing it anew, or moving the checkout onto a release by hand.
|
||
UPDATE_FAIL_REASONS=""
|
||
explain_update_failure() {
|
||
local reinstall="Или удалите мост (install.sh без INSTALL_DIR → пункт 3 «Удалить мост»; настройки и данные сначала сохраняются в бэкап в /root) и установите его заново."
|
||
case "${1:-$UPDATE_FAIL_REASONS}" in
|
||
*oldgit*)
|
||
echo " $(git --version) не умеет проверять подписи релизов — нужен git 2.34+: на Debian 11 он есть в bullseye-backports,"
|
||
echo " иначе обновите ОС до Ubuntu 22.04+ / Debian 12+ — и запустите install.sh снова." ;;
|
||
*localchanges*)
|
||
echo " В $INSTALL_DIR изменены файлы проекта — обновление только fast-forward, поверх правок не пойдёт."
|
||
echo " Посмотрите, что изменено: git -C $INSTALL_DIR status. Уберите правки (git -C $INSTALL_DIR stash) и запустите install.sh снова."
|
||
echo " $reinstall" ;;
|
||
*stale*)
|
||
echo " История установки разошлась с опубликованным релизом. Вручную переведите исходники на последний релиз:"
|
||
echo " git -C $INSTALL_DIR fetch --tags $REPO_URL main, затем git -C $INSTALL_DIR reset --hard vX.Y.Z (последний тег) — и запустите install.sh снова."
|
||
echo " $reinstall" ;;
|
||
*older*) echo " Установлена версия новее, чем сейчас есть в доступном источнике, — обновлять нечего. Если ждёте новый релиз, повторите install.sh позже." ;;
|
||
*nokeygen*) echo " Нет ssh-keygen для проверки подписи: apt-get install -y openssh-client — и запустите install.sh снова." ;;
|
||
*nosig*) echo " Подписанного релиза, до которого можно обновиться, сейчас нет. Повторите install.sh позже, после выхода релиза." ;;
|
||
*) echo " Не удалось связаться ни с GitHub, ни с резервным зеркалом. Проверьте сеть сервера и запустите install.sh снова." ;;
|
||
esac
|
||
}
|
||
|
||
# Preflight: name the reasons an install would fail BEFORE spending minutes on apt/Docker. Hard
|
||
# stops only for what can't work at all (arch, disk, a git too old to verify a release on a fresh
|
||
# install); everything else is a warning with the fix.
|
||
preflight() {
|
||
local fail=0 free_mb mem_mb hp net_ts skew
|
||
. /etc/os-release 2>/dev/null || true
|
||
case "${ID:-}" in
|
||
ubuntu | debian) echo " ОС: ${PRETTY_NAME:-?} — OK" ;;
|
||
*) echo " ⚠️ ОС ${PRETTY_NAME:-неизвестна}: скрипт рассчитан на Ubuntu/Debian (apt). Продолжаю, но без гарантий." ;;
|
||
esac
|
||
case "$(uname -m)" in
|
||
x86_64 | aarch64) echo " Архитектура: $(uname -m) — OK" ;;
|
||
*) echo " ❌ Архитектура $(uname -m) не поддерживается (нужна x86_64 или arm64)."; fail=1 ;;
|
||
esac
|
||
# Checked again after step [2/6], when git was only just installed.
|
||
if command -v git >/dev/null 2>&1 && ! git_verifies_ssh_signatures; then
|
||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||
echo " ⚠️ $(git --version) не умеет проверять подписи релизов (нужен 2.34+) — обновить мост не получится, только перенастроить."
|
||
else
|
||
echo " ❌ Без проверки подписи релиза установка невозможна:"
|
||
explain_update_failure oldgit
|
||
fail=1
|
||
fi
|
||
fi
|
||
free_mb=$(df -Pm / | awk 'NR==2{print $4}')
|
||
if [ "${free_mb:-0}" -lt 3072 ]; then
|
||
echo " ❌ Свободно ${free_mb} МБ на / — нужно минимум 3 ГБ (образ ~1.7 ГБ + сборка)."; fail=1
|
||
else
|
||
echo " Диск: свободно ${free_mb} МБ — OK"
|
||
fi
|
||
mem_mb=$(awk '/MemTotal/{printf "%d", $2/1024}' /proc/meminfo)
|
||
if [ "${mem_mb:-0}" -lt 900 ]; then
|
||
echo " ⚠️ RAM ${mem_mb} МБ — сборка образа с Chromium может не пройти; при установке выберите slim (лёгкий образ)."
|
||
else
|
||
echo " RAM: ${mem_mb} МБ — OK"
|
||
fi
|
||
for hp in api.telegram.org:443 api2.oneme.ru:443 zergont-gate.duckdns.org:443; do
|
||
if timeout 5 bash -c "cat </dev/null >/dev/tcp/${hp%%:*}/${hp##*:}" 2>/dev/null; then
|
||
echo " Сеть: ${hp%%:*} — OK"
|
||
else
|
||
echo " ⚠️ Сеть: ${hp%%:*} недоступен (для Telegram может помочь прокси — setup.sh спросит; MAX и зеркало нужны напрямую)."
|
||
fi
|
||
done
|
||
net_ts=$(curl -fsSI --max-time 8 https://api.telegram.org 2>/dev/null | tr -d '\r' | awk -F': ' 'tolower($1)=="date"{print $2}')
|
||
if [ -n "$net_ts" ]; then
|
||
skew=$(( $(date +%s) - $(date -d "$net_ts" +%s 2>/dev/null || date +%s) )); skew=${skew#-}
|
||
if [ "$skew" -gt 300 ]; then
|
||
echo " ⚠️ Часы сервера расходятся с реальным временем на ${skew}с — TLS/Telegram могут отказывать. Обычно лечит: timedatectl set-ntp true"
|
||
else
|
||
echo " Время: OK"
|
||
fi
|
||
fi
|
||
[ "$fail" -eq 0 ]
|
||
}
|
||
|
||
bold "=== Telemax — установка ==="
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Existing bridges: find every configured install on this host and, when the
|
||
# run is interactive and the caller didn't pin INSTALL_DIR, offer a menu —
|
||
# update/reconfigure one, add another, or remove one/all. Sources: the 0.6.4
|
||
# auto-update registry, Docker compose labels (catches installs that predate
|
||
# the registry or fell out of it) and the default /opt/telemax* location.
|
||
# A "bridge" = a directory holding a Telemax checkout (docker-compose.yml +
|
||
# update-watcher.sh). One without .env — an interrupted install — is listed as
|
||
# «не настроен», so the menu can finish or remove it instead of losing it.
|
||
# ---------------------------------------------------------------------------
|
||
discover_installs() {
|
||
{
|
||
cat /etc/telemax/instances 2>/dev/null || true
|
||
docker ps -a --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null || true
|
||
ls -d /opt/telemax* 2>/dev/null || true
|
||
} | sort -u | while IFS= read -r d; do
|
||
if [ -n "$d" ] && [ -f "$d/docker-compose.yml" ] && [ -f "$d/update-watcher.sh" ]; then printf '%s\n' "$d"; fi
|
||
done
|
||
}
|
||
install_version() { sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' "$1/package.json" 2>/dev/null | head -1; }
|
||
install_status() {
|
||
if [ ! -f "$1/.env" ]; then printf '⚙️ не настроен'; return 0; fi
|
||
# Container names live under the compose project name — setup.sh writes it into .env
|
||
# since 0.6.4; older installs derive it from the directory basename (same rule).
|
||
local name
|
||
name=$(sed -n 's/^COMPOSE_PROJECT_NAME=//p' "$1/.env" 2>/dev/null | head -1 | tr -d "\"'")
|
||
[ -n "$name" ] || name=$(basename "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g')
|
||
if [ -n "$(docker ps -q --filter "label=com.docker.compose.project=$name" 2>/dev/null)" ]; then
|
||
printf '🟢 работает'
|
||
elif [ -n "$(docker ps -aq --filter "label=com.docker.compose.project=$name" 2>/dev/null)" ]; then
|
||
printf '🔴 остановлен'
|
||
else
|
||
printf '⚪ контейнера нет'
|
||
fi
|
||
}
|
||
# Saves the only irreplaceable parts (.env = tokens/keys, data/ = MAX session + chat map)
|
||
# before anything destructive. Prints the archive path, or nothing if there was nothing to save.
|
||
# Returns NON-ZERO when there WAS something to save and no archive came out — callers must then
|
||
# refuse to delete anything. (It used to fail silently: an `if` without `else` returns 0, so under
|
||
# `set -e` the caller went straight on to `rm -rf` after the menu had promised a backup.)
|
||
backup_install() {
|
||
local dir="$1" slug base out items=() rc=0 n=0
|
||
slug=$(basename "$dir" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g')
|
||
base="/root/telemax-backup-${slug:-telemax}-$(date +%Y%m%d-%H%M%S)"
|
||
# Never over an existing archive: /opt/telemax and /root/Telemax share a slug, and two backups
|
||
# in the same second replaced the first one after its install was deleted (review 2026-09-27, shell-r3.1#6).
|
||
out="$base.tar.gz"
|
||
while [ -e "$out" ]; do n=$((n + 1)); out="$base-$n.tar.gz"; done
|
||
[ -f "$dir/.env" ] && items+=(.env)
|
||
[ -d "$dir/data" ] && items+=(data)
|
||
[ "${#items[@]}" -gt 0 ] || return 0
|
||
# umask 077: the archive holds the bot token and MAX_SESSION_KEY — root-only, like .env itself
|
||
# (root's default umask would have made it world-readable 0644). GNU tar exits 1 for "file
|
||
# changed as we read it" (the running bridge writes to data/) — the archive is still written, so
|
||
# only >= 2 (fatal) counts as a failure. tar's own stderr stays visible so the reason is on screen.
|
||
( umask 077; tar -czf "$out" -C "$dir" "${items[@]}" ) >&2 || rc=$?
|
||
if [ "$rc" -le 1 ] && [ -s "$out" ]; then
|
||
printf '%s' "$out"
|
||
return 0
|
||
fi
|
||
rm -f "$out"
|
||
return 1
|
||
}
|
||
delete_install() {
|
||
local dir="$1" bak ids
|
||
echo "Удаляю мост в $dir..."
|
||
if ! bak=$(backup_install "$dir"); then
|
||
echo "❌ Не удалось сохранить бэкап настроек и данных ($dir/.env, $dir/data) в /root — мост НЕ удаляю."
|
||
echo " Проверьте место на диске (df -h /root) и запустите install.sh снова."
|
||
exit 1
|
||
fi
|
||
if [ -n "$bak" ]; then echo " На всякий случай настройки и данные сохранены: $bak"; fi
|
||
if command -v docker >/dev/null 2>&1; then
|
||
# compose needs .env (env_file, COMPOSE_PROJECT_NAME): without it, it fails on older Compose or
|
||
# falls back to the directory name — another bridge's project. Whatever still carries this
|
||
# directory's label is removed either way, or it kept relaying with the old token.
|
||
if [ -f "$dir/docker-compose.yml" ] && [ -f "$dir/.env" ]; then
|
||
(cd "$dir" && docker compose down -v --rmi local --remove-orphans) </dev/null || true
|
||
fi
|
||
ids=$(docker ps -aq --filter "label=com.docker.compose.project.working_dir=$dir" 2>/dev/null || true)
|
||
[ -z "$ids" ] || docker rm -f $ids >/dev/null 2>&1 || true
|
||
fi
|
||
rm -rf "$dir"
|
||
if [ -f /etc/telemax/instances ]; then
|
||
grep -vxF "$dir" /etc/telemax/instances > /etc/telemax/instances.tmp 2>/dev/null || true
|
||
mv /etc/telemax/instances.tmp /etc/telemax/instances
|
||
fi
|
||
git config --system --fixed-value --unset-all safe.directory "$dir" 2>/dev/null || true
|
||
echo " Мост $dir удалён."
|
||
}
|
||
# The auto-update timer, dispatcher and registry are host-shared — remove them only when the
|
||
# LAST bridge is gone. Docker and Fail2ban stay: they belong to the server, not to Telemax.
|
||
maybe_remove_updater() {
|
||
if [ -z "$(discover_installs)" ]; then
|
||
systemctl disable --now telemax-updater.timer >/dev/null 2>&1 || true
|
||
rm -f /etc/systemd/system/telemax-updater.timer /etc/systemd/system/telemax-updater.service /usr/local/sbin/telemax-updater
|
||
# The shared big-file service (files-service/reconcile.sh) and its instant-start trigger.
|
||
systemctl disable --now telemax-files.path >/dev/null 2>&1 || true
|
||
rm -f /etc/systemd/system/telemax-files.path /etc/systemd/system/telemax-files.service
|
||
if [ -f /var/lib/telemax-files/compose.yml ]; then
|
||
docker compose -p telemax-files -f /var/lib/telemax-files/compose.yml down >/dev/null 2>&1 || true
|
||
fi
|
||
rm -rf /var/lib/telemax-files
|
||
rm -f /etc/telemax/instances
|
||
rmdir /etc/telemax 2>/dev/null || true
|
||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||
echo "Это был последний мост — общий таймер автообновления тоже убран (Docker и Fail2ban не тронуты: они серверные)."
|
||
fi
|
||
}
|
||
# Prints the chosen directory from EXISTING, or nothing on a bad answer. With a single
|
||
# install there is nothing to ask.
|
||
pick_install() {
|
||
local n="${#EXISTING[@]}" c=""
|
||
if [ "$n" -eq 1 ]; then printf '%s' "${EXISTING[0]}"; return 0; fi
|
||
read -rp "$1 [1-$n]: " c </dev/tty || true
|
||
case "$c" in '' | *[!0-9]*) return 0 ;; esac
|
||
if [ "$c" -ge 1 ] && [ "$c" -le "$n" ]; then printf '%s' "${EXISTING[$((c - 1))]}"; fi
|
||
}
|
||
|
||
CLEAN_REINSTALL=0
|
||
if [ "$INSTALL_DIR_EXPLICIT" -eq 0 ] && bash -c ': </dev/tty' 2>/dev/null; then
|
||
mapfile -t EXISTING < <(discover_installs)
|
||
if [ "${#EXISTING[@]}" -gt 0 ]; then
|
||
echo "На этом сервере уже есть мост(ы) Telemax:"
|
||
_i=1
|
||
for _d in "${EXISTING[@]}"; do
|
||
printf ' %d) %s — v%s, %s\n' "$_i" "$_d" "$(install_version "$_d")" "$(install_status "$_d")"
|
||
_i=$((_i + 1))
|
||
done
|
||
echo
|
||
echo "Что сделать?"
|
||
echo " 1) Обновить или перенастроить существующий мост"
|
||
echo " 2) Установить ещё один, НОВЫЙ мост (ему нужны свой бот, своя группа и свой номер MAX)"
|
||
echo " 3) Удалить мост"
|
||
echo " 0) Ничего, выйти"
|
||
MENU_CHOICE=""
|
||
read -rp "Выбор [1]: " MENU_CHOICE </dev/tty || true
|
||
case "${MENU_CHOICE:-1}" in
|
||
2)
|
||
_i=2
|
||
while [ -e "/opt/telemax-$_i" ]; do _i=$((_i + 1)); done
|
||
NEW_DIR=""
|
||
read -rp "Каталог для нового моста [/opt/telemax-$_i]: " NEW_DIR </dev/tty || true
|
||
INSTALL_DIR="${NEW_DIR:-/opt/telemax-$_i}"
|
||
;;
|
||
3)
|
||
TARGET=""
|
||
if [ "${#EXISTING[@]}" -gt 1 ]; then
|
||
echo "Какой удалить? (0 — ВСЕ мосты)"
|
||
DEL_CHOICE=""
|
||
read -rp "Номер [ничего не удалять]: " DEL_CHOICE </dev/tty || true
|
||
if [ "$DEL_CHOICE" = "0" ]; then TARGET="ALL"; else
|
||
case "$DEL_CHOICE" in *[!0-9]* | '') TARGET="" ;; *)
|
||
if [ "$DEL_CHOICE" -ge 1 ] && [ "$DEL_CHOICE" -le "${#EXISTING[@]}" ]; then TARGET="${EXISTING[$((DEL_CHOICE - 1))]}"; fi ;;
|
||
esac
|
||
fi
|
||
else
|
||
TARGET="${EXISTING[0]}"
|
||
fi
|
||
if [ -z "$TARGET" ]; then echo "Не понял выбор — ничего не удаляю."; exit 0; fi
|
||
if [ "$TARGET" = "ALL" ]; then
|
||
echo "⚠️ Будут удалены ВСЕ мосты: контейнеры, образы и каталоги со всеми данными."
|
||
else
|
||
echo "⚠️ Будет удалён мост $TARGET: контейнер, образ и каталог со всеми данными."
|
||
fi
|
||
CONFIRM=""
|
||
read -rp "Точно? Введите «да»: " CONFIRM </dev/tty || true
|
||
case "$CONFIRM" in да | Да | ДА) : ;; *) echo "Отменено — ничего не удалял."; exit 0 ;; esac
|
||
if [ "$TARGET" = "ALL" ]; then
|
||
for _d in "${EXISTING[@]}"; do delete_install "$_d"; done
|
||
else
|
||
delete_install "$TARGET"
|
||
fi
|
||
maybe_remove_updater
|
||
echo "Готово."
|
||
exit 0
|
||
;;
|
||
0)
|
||
echo "Выход — ничего не менял."
|
||
exit 0
|
||
;;
|
||
*)
|
||
TARGET=$(pick_install "Какой мост обновить/перенастроить?")
|
||
if [ -z "$TARGET" ]; then echo "Не понял выбор — выхожу, ничего не менял."; exit 0; fi
|
||
INSTALL_DIR="$TARGET"
|
||
echo " 1) Обновить (настройки сохраняются, откроется меню настроек)"
|
||
echo " 2) Переустановить НАЧИСТО (настройки и данные сбросить; перед этим сохраню их в бэкап)"
|
||
SUB_CHOICE=""
|
||
read -rp "Выбор [1]: " SUB_CHOICE </dev/tty || true
|
||
if [ "${SUB_CHOICE:-1}" = "2" ]; then CLEAN_REINSTALL=1; fi
|
||
;;
|
||
esac
|
||
fi
|
||
fi
|
||
|
||
echo "Каталог установки: $INSTALL_DIR"
|
||
if [ -f "$INSTALL_DIR/.env" ] && [ "$CLEAN_REINSTALL" -eq 0 ]; then
|
||
echo
|
||
echo "ℹ️ В $INSTALL_DIR уже есть НАСТРОЕННАЯ установка — обновлю её и открою меню настроек."
|
||
echo " Нужен ВТОРОЙ независимый мост на этом сервере? Укажите другой каталог:"
|
||
echo " curl -fsSL https://zergont-gate.duckdns.org/install.sh | INSTALL_DIR=/opt/telemax-2 bash"
|
||
echo " (у него должен быть свой бот, своя группа и свой номер MAX)"
|
||
fi
|
||
echo
|
||
|
||
# Everything below is also written to a log file — attach it to a bug report if something fails.
|
||
# Per-directory, so a second install doesn't interleave its output into the first one's log.
|
||
# After the menu on purpose: the log belongs to the directory the user actually chose.
|
||
INSTALL_SLUG=$(basename "$INSTALL_DIR" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g')
|
||
INSTALL_LOG="/var/log/telemax-install-${INSTALL_SLUG:-telemax}.log"
|
||
exec > >(tee -a "$INSTALL_LOG") 2>&1
|
||
echo "(лог установки: $INSTALL_LOG)"
|
||
|
||
echo "[0/6] Проверяю сервер..."
|
||
preflight || { echo "❌ Сервер не подходит для установки — см. причины выше."; exit 1; }
|
||
echo
|
||
echo "[1/6] Обновляю систему (может занять несколько минут)..."
|
||
apt_get update -y
|
||
apt_get upgrade -y
|
||
|
||
echo
|
||
echo "[2/6] Проверяю git и jq..."
|
||
# jq: used by setup.sh to auto-detect the Telegram group id (no need to hunt for it
|
||
# manually — see setup.sh). The release-signature check (below when updating an existing
|
||
# checkout, and in update.sh) uses ssh-keygen from openssh-client — normally already present on
|
||
# any host you can SSH into; installed here only if it is missing.
|
||
for pkg in git jq; do
|
||
if ! command -v "$pkg" >/dev/null 2>&1; then
|
||
apt_get install -y "$pkg"
|
||
fi
|
||
done
|
||
command -v ssh-keygen >/dev/null 2>&1 || apt_get install -y openssh-client
|
||
# A git installed just now is the distro's — on an older release too old to verify a signed release.
|
||
# A fresh install stops here; an existing checkout (warned in preflight) can still be reconfigured.
|
||
BLOCKER=$(verify_blocker)
|
||
if [ -n "$BLOCKER" ] && [ ! -d "$INSTALL_DIR/.git" ]; then
|
||
echo "❌ Без проверки подписи релиза установка невозможна:"
|
||
explain_update_failure "$BLOCKER"
|
||
exit 1
|
||
fi
|
||
|
||
echo
|
||
echo "[3/6] Проверяю Docker..."
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
# get.docker.com's own script calls apt-get install internally and can hit
|
||
# the exact same recurring trigger issue — don't trust its exit code alone,
|
||
# check whether docker actually landed afterward.
|
||
# Never hand Docker's installer a half-configured dpkg — its own apt-get would fail on it.
|
||
dpkg --configure -a $DPKG_NI >/dev/null 2>&1 </dev/null || true
|
||
curl -fsSL https://get.docker.com | sh || true
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
# get.docker.com unreachable/blocked or its repo setup failed — fall back to the distro packages
|
||
# (docker.io + the compose v2 plugin): older, but plenty for this bridge.
|
||
echo "Установщик Docker не сработал — пробую пакеты дистрибутива (docker.io)..."
|
||
apt_get install -y docker.io
|
||
apt_get install -y docker-compose-v2
|
||
docker compose version >/dev/null 2>&1 || apt_get install -y docker-compose-plugin
|
||
fi
|
||
if ! command -v docker >/dev/null 2>&1; then
|
||
echo "❌ Docker всё ещё не установлен после двух попыток. Смотрите ошибки выше и лог $INSTALL_LOG, затем запустите install.sh заново."
|
||
exit 1
|
||
fi
|
||
if ! docker compose version >/dev/null 2>&1; then
|
||
echo "❌ Docker есть, но нет Docker Compose v2 (команда 'docker compose'). Установите плагин: apt-get install docker-compose-v2 (или docker-compose-plugin) — и запустите install.sh заново."
|
||
exit 1
|
||
fi
|
||
else
|
||
echo "Docker уже установлен."
|
||
fi
|
||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||
|
||
echo
|
||
echo "[4/6] Защита сервера от подбора пароля по SSH (Fail2ban)..."
|
||
# The bridge opens no inbound ports (MAX = outbound TCP, Telegram = long-polling), so the only
|
||
# door on this host is SSH — and every public server gets hammered with password guesses within
|
||
# hours of going online. Fail2ban's stock `sshd` jail bans a source after 5 failures in 10 min
|
||
# (for 10 min). Offered, not forced: default YES, 30s timeout so an unattended run isn't
|
||
# blocked. Read from /dev/tty — under `curl | bash` stdin is the script itself. The `if` keeps a
|
||
# timed-out read from tripping `set -e`.
|
||
#
|
||
# The jail reads the journal (`backend = systemd`) — chosen because minimal Ubuntu 22.04/24.04
|
||
# images ship no /var/log/auth.log, where fail2ban's default backend can't start the sshd jail at
|
||
# all. That backend needs the python3-systemd bindings, which Ubuntu/Debian do NOT pull in as a
|
||
# fail2ban dependency. 0.5.0 shipped without them: the service came up, the jail silently died
|
||
# ("No module named 'systemd'") and the script still reported success — caught live on the test
|
||
# box 2026-09-05. Hence: install both, and verify the JAIL (not the service) before claiming
|
||
# protection.
|
||
f2b_jail_up() {
|
||
# The server needs a moment after start; a status query before "Server ready" fails spuriously.
|
||
local i
|
||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||
if fail2ban-client status sshd >/dev/null 2>&1; then return 0; fi
|
||
sleep 1
|
||
done
|
||
return 1
|
||
}
|
||
f2b_report() {
|
||
if f2b_jail_up; then
|
||
echo "Fail2ban включён: jail sshd работает${F2B_SELF_IP:+, ваш IP ${F2B_SELF_IP} в белом списке}."
|
||
else
|
||
echo "⚠️ Fail2ban запущен, но jail sshd не поднялся — защиты SSH НЕТ. Причина из лога:"
|
||
grep -E "ERROR" /var/log/fail2ban.log 2>/dev/null | tail -3 | sed 's/^/ /' || true
|
||
echo " Проверьте: fail2ban-client status sshd"
|
||
fi
|
||
}
|
||
if command -v fail2ban-client >/dev/null 2>&1; then
|
||
# Already installed. One targeted self-heal: a config on the systemd backend (ours from 0.5.0,
|
||
# or the user's own) with the bindings missing = a jail that never started. Add the module and
|
||
# restart; everything else about an existing setup is left alone.
|
||
if grep -qs '^backend *= *systemd' /etc/fail2ban/jail.local 2>/dev/null \
|
||
&& ! python3 -c 'import systemd.journal' >/dev/null 2>&1; then
|
||
echo "Fail2ban уже стоит, но без python3-systemd его jail sshd не работает — доставляю модуль..."
|
||
apt_get install -y python3-systemd
|
||
systemctl restart fail2ban >/dev/null 2>&1 </dev/null || true
|
||
F2B_SELF_IP=""; f2b_report
|
||
else
|
||
echo "Fail2ban уже установлен — пропускаю."
|
||
fi
|
||
else
|
||
F2B_CHOICE=""
|
||
if read -t 30 -rp "Поставить Fail2ban? [Y/n] (жду 30с, по умолчанию — да): " F2B_CHOICE </dev/tty; then :; else echo; fi
|
||
case "${F2B_CHOICE:-}" in
|
||
n | N | no | NO | нет | Нет) echo "Пропускаю Fail2ban." ;;
|
||
*)
|
||
apt_get install -y fail2ban python3-systemd
|
||
# Whitelist the IP of this very SSH session so the installer can't lock THEMSELVES out by
|
||
# mistyping their password (SSH_CONNECTION is exported by sshd; empty when not over SSH).
|
||
# Only written on a fresh install (the command -v branch above never clobbers a user's own
|
||
# jail.local).
|
||
F2B_SELF_IP="${SSH_CONNECTION:-}"
|
||
F2B_SELF_IP="${F2B_SELF_IP%% *}"
|
||
cat > /etc/fail2ban/jail.local <<EOF
|
||
[DEFAULT]
|
||
backend = systemd
|
||
ignoreip = 127.0.0.1/8 ::1 ${F2B_SELF_IP}
|
||
|
||
[sshd]
|
||
enabled = true
|
||
EOF
|
||
systemctl enable --now fail2ban >/dev/null 2>&1 </dev/null || true
|
||
f2b_report
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# A fresh clone and an existing checkout are moved only onto a release whose tag verifies against the
|
||
# PINNED key (a clone carries nothing trusted yet; a very old checkout carries no key at all). Prints
|
||
# the newest v* tag in the history of $2 (main's tip) whose signature verifies — a release on a side
|
||
# branch does not count, untagged work after the newest release is skipped. Only final releases
|
||
# (vX.Y.Z): `sort -rV` ranks v1.1.0-beta.1 above v1.1.0, so a pre-release tag would win a fresh
|
||
# install. Non-zero when there is none.
|
||
newest_signed_release() {
|
||
local t
|
||
for t in $(git -C "$1" tag -l 'v[0-9]*' 2>/dev/null | grep -E '^v[0-9]+(\.[0-9]+)*$' | sort -rV); do
|
||
git -C "$1" merge-base --is-ancestor "$t" "$2" 2>/dev/null || continue
|
||
if git -C "$1" -c gpg.format=ssh -c gpg.ssh.allowedSignersFile="$SIGNERS" verify-tag "$t" >/dev/null 2>&1; then
|
||
printf '%s' "$t"
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
# An EXISTING checkout follows update.sh's order: fetch -> verify the release in the INCOMING main ->
|
||
# only then fast-forward, so nothing unverified ever reaches the working tree. Not delegated to
|
||
# update.sh: the checkout's copy is the OLD release's, trusts the checkout's own allowed_signers and
|
||
# rebuilds/restarts/reports to Telegram — here setup.sh comes next and decides that. Fetches from $2
|
||
# into FETCH_HEAD without touching the tree and sets RELEASE to the verified tag; a failed attempt
|
||
# appends its reason to UPDATE_FAIL_REASONS. GIT_TERMINAL_PROMPT=0: a flagged GitHub fails fast.
|
||
RELEASE=""
|
||
fetch_release() {
|
||
local dir="$1" url="$2" label="$3" tip t
|
||
echo " Получаю main (${label})..."
|
||
if ! GIT_TERMINAL_PROMPT=0 git -C "$dir" fetch "$url" main >/dev/null 2>&1; then
|
||
echo " ${label}: недоступен"
|
||
UPDATE_FAIL_REASONS="$UPDATE_FAIL_REASONS network"
|
||
return 1
|
||
fi
|
||
# main's tip is taken BEFORE the tag fetch, which overwrites FETCH_HEAD with the tags themselves.
|
||
tip=$(git -C "$dir" rev-parse FETCH_HEAD)
|
||
GIT_TERMINAL_PROMPT=0 git -C "$dir" fetch "$url" "+refs/tags/*:refs/tags/*" >/dev/null 2>&1 || echo " ${label}: теги не скачались"
|
||
if ! t=$(newest_signed_release "$dir" "$tip"); then
|
||
echo " ${label}: в main нет корректно подписанного релиза"
|
||
UPDATE_FAIL_REASONS="$UPDATE_FAIL_REASONS nosig"
|
||
return 1
|
||
fi
|
||
# An install newer than the source (it lags behind, e.g. a frozen GitHub) needs nothing done;
|
||
# only a diverged history does (review 2026-09-27, shell-r3.1#3).
|
||
if ! git -C "$dir" merge-base --is-ancestor HEAD "$t" 2>/dev/null; then
|
||
if git -C "$dir" merge-base --is-ancestor "$t" HEAD 2>/dev/null; then
|
||
echo " ${label}: там релиз ${t}, а установлен более новый — не подходит"
|
||
UPDATE_FAIL_REASONS="$UPDATE_FAIL_REASONS older"
|
||
else
|
||
echo " ${label}: история установки разошлась с релизом ${t} — не подходит"
|
||
UPDATE_FAIL_REASONS="$UPDATE_FAIL_REASONS stale"
|
||
fi
|
||
return 1
|
||
fi
|
||
echo " ${label}: подпись релиза OK (ssh): ${t}"
|
||
RELEASE="$t"
|
||
}
|
||
# Returns non-zero (tree untouched) when no source offers a verified release or it can't be
|
||
# fast-forwarded to.
|
||
update_existing_checkout() {
|
||
local dir="$1"
|
||
if [ -n "$BLOCKER" ]; then
|
||
echo " Подпись релиза проверить нечем."
|
||
UPDATE_FAIL_REASONS="$BLOCKER"
|
||
return 1
|
||
fi
|
||
fetch_release "$dir" origin "GitHub" || fetch_release "$dir" "$MIRROR_GIT_URL" "зеркало" || return 1
|
||
if ! git -C "$dir" merge --ff-only "$RELEASE^{commit}" >/dev/null 2>&1; then
|
||
echo " Локальные изменения мешают обновиться (нужен fast-forward)."
|
||
UPDATE_FAIL_REASONS="$UPDATE_FAIL_REASONS localchanges"
|
||
return 1
|
||
fi
|
||
echo "Исходники на релизе ${RELEASE} (подпись проверена)."
|
||
}
|
||
|
||
echo
|
||
echo "[5/6] Скачиваю проект в $INSTALL_DIR..."
|
||
CHECKOUT_UNCHANGED=0 # 1 when an existing checkout was left exactly as it was (no verified release)
|
||
FRESH_CLONE=0 # 1 when $INSTALL_DIR was cloned just now (nothing user-owned in it yet)
|
||
if [ -d "$INSTALL_DIR/.git" ]; then
|
||
echo "Уже склонировано — обновляю до последнего подписанного релиза..."
|
||
# A failure leaves the install on its current, already-trusted version (the tree only moves after
|
||
# a successful verification) — setup.sh still runs, e.g. to reconfigure a proxy — with the advice
|
||
# fitting the reason: a diverged install used to get none, and update.sh refuses it too (shell-r2#0).
|
||
if ! update_existing_checkout "$INSTALL_DIR"; then
|
||
echo "⚠️ Не обновил (причины выше) — остаюсь на установленной версии."
|
||
explain_update_failure
|
||
CHECKOUT_UNCHANGED=1
|
||
fi
|
||
elif [ -d "$INSTALL_DIR" ] && [ -n "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then
|
||
# git clone refuses a non-empty directory; without this the failure read as "GitHub недоступен".
|
||
echo "❌ $INSTALL_DIR не пуст и не является git-репозиторием: удалите мост через меню (пункт 3, с бэкапом) или укажите другой каталог"
|
||
exit 1
|
||
elif GIT_TERMINAL_PROMPT=0 git clone "$REPO_URL" "$INSTALL_DIR"; then
|
||
FRESH_CLONE=1 # cloned from GitHub
|
||
else
|
||
echo "GitHub недоступен — устанавливаю с резервного зеркала..."
|
||
git clone "$MIRROR_GIT_URL" "$INSTALL_DIR"
|
||
FRESH_CLONE=1
|
||
# Keep origin pointing at GitHub (the canonical source) so ordinary updates prefer it once it's
|
||
# back; the mirror stays the fallback (see update.sh).
|
||
git -C "$INSTALL_DIR" remote set-url origin "$REPO_URL"
|
||
fi </dev/null # same stdin guard as the apt calls — keep git off the curl|bash pipe
|
||
|
||
# A fresh clone is built only from a signed release (review 2026-09-26, shell-r1#0): it used to build
|
||
# main HEAD with no signature check at all, so a tampered mirror ran its own setup.sh as root. With
|
||
# none it goes again — git clone only succeeds into a new or empty directory, nothing of the user's is in it.
|
||
if [ "$FRESH_CLONE" -eq 1 ]; then
|
||
if RELEASE=$(newest_signed_release "$INSTALL_DIR" HEAD) && git -C "$INSTALL_DIR" checkout -q -B main "$RELEASE^{commit}" </dev/null; then
|
||
echo "Исходники на релизе ${RELEASE} (подпись проверена)."
|
||
else
|
||
rm -rf "$INSTALL_DIR"
|
||
echo "❌ В скачанном репозитории нет релиза с действительной подписью — установка остановлена."
|
||
echo " Возможна подмена источника (GitHub/зеркала) или временный сбой. Повторите install.sh позже."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
cd "$INSTALL_DIR"
|
||
|
||
# Bootstrap-trust check (see EXPECTED_SIGNER above): the cloned repo must carry exactly the
|
||
# pinned release key. Missing file counts as failure — every release since v0.4.1 ships it.
|
||
if ! grep -qxF "$EXPECTED_SIGNER" allowed_signers 2>/dev/null; then
|
||
# An existing checkout the update left untouched that predates allowed_signers (before v0.4.1) has
|
||
# no key to check yet: still stopped, but not reported as a compromised source (review 2026-09-26,
|
||
# b6-installer-docs); the advice fitting the reason was printed where the update failed (shell-r1#1).
|
||
if [ "$CHECKOUT_UNCHANGED" -eq 1 ] && [ ! -e allowed_signers ]; then
|
||
echo "❌ Установка слишком старая (до v0.4.1): в ней ещё нет ключа подписи релизов, и обновить её не вышло (что делать — см. выше)."
|
||
else
|
||
echo "❌ Ключ подписи релизов в скачанном репозитории не совпадает с ожидаемым."
|
||
echo " Возможна компрометация источника (GitHub/зеркала) — установка остановлена."
|
||
fi
|
||
exit 1
|
||
fi
|
||
|
||
# Clean reinstall (menu choice): wipe the configuration and the bridge state so setup.sh
|
||
# runs its first-time flow. The backup happens HERE, right before the wipe — not at menu
|
||
# time — so it captures the very last state. The git checkout stays (already updated above).
|
||
if [ "$CLEAN_REINSTALL" -eq 1 ]; then
|
||
echo
|
||
echo "Переустановка начисто: сбрасываю настройки и данные..."
|
||
if ! BAK=$(backup_install "$INSTALL_DIR"); then
|
||
echo "❌ Не удалось сохранить бэкап настроек и данных в /root — переустановку начисто отменяю, ничего не удалено."
|
||
echo " Проверьте место на диске (df -h /root) и запустите install.sh снова."
|
||
exit 1
|
||
fi
|
||
if [ -n "$BAK" ]; then echo " Старые настройки и данные сохранены: $BAK"; fi
|
||
docker compose down -v --remove-orphans </dev/null 2>/dev/null || true
|
||
rm -f .env
|
||
rm -rf data
|
||
fi
|
||
|
||
echo
|
||
bold "Окружение готово, переходим к настройке."
|
||
echo
|
||
# The update above moved only the sources: the running container stays on its version until it is
|
||
# rebuilt, and leaving the menu with «0» rebuilds nothing (review 2026-09-27, shell-r3.1#1). Only when
|
||
# the container really runs another commit (its GIT_COMMIT, as update.sh reads it): an install already
|
||
# on the release was told to rebuild for nothing (shell-r3.2#0).
|
||
DEPLOYED=$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$(docker compose ps -q 2>/dev/null | head -1)" 2>/dev/null | sed -n 's/^GIT_COMMIT=//p' | head -1) || DEPLOYED=""
|
||
if [ "$FRESH_CLONE" -eq 0 ] && [ "$CHECKOUT_UNCHANGED" -eq 0 ] && [ -f .env ] && [ "$DEPLOYED" != "$(git rev-parse HEAD)" ]; then
|
||
echo "ℹ️ Новая версия заработает после пересборки: в меню ниже выберите «6) Пересобрать и перезапустить контейнер»."
|
||
echo
|
||
fi
|
||
|
||
# curl | bash consumes stdin for the script itself — reconnect to the real
|
||
# terminal so setup.sh's prompts (bot token, group id) actually work.
|
||
# `exec` replaces this shell, so the EXIT trap above will NOT fire — clean up here.
|
||
rm -f "$APT_NI_SNIPPET" "$SIGNERS"
|
||
exec ./setup.sh < /dev/tty
|