mbs-panel/install.sh
savsis 7cc50973f6 feat: custom brand name everywhere + a working client-facing site out of the box
User ask, paraphrased: install it, get help wiring up payments, and
immediately have a ready site under your own name — not "MBS Panel"
plastered everywhere and a bunch of manual follow-up.

Two things were actually broken/missing, found by tracing every surface
a real customer or the operator would see:

1. "MBS Panel" was hardcoded in ~20 places (bot messages, subscription
   page, admin panel splash/title/sidebar, legal pages, 2FA issuer,
   install.sh) with zero way to change it short of editing source.
   New BRAND_NAME config value (config.py default "MBS Panel", so this
   is 100% backward compatible for existing installs) wired through
   everywhere via the same live-settings pattern from the last commit
   (settings.get_brand_name(), no restart needed anywhere it's used).
   New Настройки → «Название» section in the admin panel to change it.

2. site/index.html and site/cabinet.html — a fully-built landing page +
   personal-cabinet template, already in the repo — were never actually
   served by anything. Not mounted by FastAPI, not deployed by
   install.sh, not linked from anywhere. Pure dead weight: a repo that
   looked like it shipped a client site but didn't. Now legal.py gets a
   render_site_page() (same {{TOKEN}} substitution + HTML-escaping as
   the existing offer/privacy renderer, new tokens: BRAND_NAME,
   SITE_DOMAIN, SUB_DOMAIN, BOT_USERNAME) and GET "/" serves the branded
   landing page on any host that isn't PANEL_DOMAIN (in practice:
   SUB_DOMAIN, which nginx already routes to this backend — zero
   install.sh/nginx/certbot changes needed, so this is live on every
   existing install without an upgrade step beyond `mbs update`).
   GET /cabinet.html serves the cabinet. Landing page's pricing section
   now fetches real, live prices from a new public GET /api/plans
   instead of showing static duration labels with no numbers.

Also fixed along the way, same staleness-bug class as the payments/HWID
fix last commit, found by grepping for every remaining frozen `from
config import ...` in api.py: BOT_TOKEN/BOT_USERNAME were still frozen
constants in api.py (mbs-api never restarts itself). Concretely this
meant: changing the bot via Настройки → Telegram-бот would leave
_tg_send_message (payment-received notifications) silently trying the
OLD token, admin_get_bot_settings showing the OLD username right after
a successful save, and gift-code links pointing at the OLD bot — all
until a manual mbs restart, same shape as the Platega-secret bug fixed
last commit. Added settings.bot_credentials(), wired it through every
call site (hoisted out of loops where relevant, same N+1 discipline as
always), removed the now-stale "выполни mbs restart" copy from the bot
settings hint.

legal.py's own BOT_USERNAME import was frozen too (used by the /offer
and /privacy {{BOT_USERNAME}} token) — switched to reading it live
in-module (no settings.py import from legal.py, would've been circular
since settings.py already imports legal.py for the env reader).

install.sh: new interactive prompt for the brand name (default "MBS
Panel", so hitting enter reproduces today's behavior exactly), written
to .env, echoed in the final summary along with the now-live site URL.

Verification: same story as always — api.py/bot.py still can't import
locally (no pydantic-core wheel for Python 3.14 on this machine).
py_compile + pyflakes clean across the whole repo. Real runtime test
against an isolated .env fixture: brand name and bot-credential live
reads (no reimport), render_site_page() token substitution correctness
on the actual site/index.html and site/cabinet.html files including an
XSS check (brand name containing <script> comes out HTML-escaped), and
a regression check that adding the BRAND_NAME token to the existing
legal.render() didn't break offer.html/privacy.html. Extracted
SUB_PAGE_TEMPLATE/SUB_PAGE_EXPIRED_TEMPLATE via ast from api.py (can't
import the module, but can pull the string constants) and ran the real
.format() calls against them to catch any brace-escaping mistake in the
new {brand_name} placeholder — CSS braces in those templates are
already double-escaped for .format(), easy to get wrong. Extracted and
node --check'd admin.html's whole inline script, div-tag-balance check
on the full file. install.sh's new prompt+heredoc snippet run standalone
with piped stdin (both a brand name with spaces and an empty/default
input), round-tripped the resulting .env back through the real
env-parsing logic. Extended the existing CI "app wiring" step (which
does import api/bot for real on Linux) with branding assertions calling
the actual route functions directly (api.root(), api.public_plans(),
api.public_branding()) — ran every part of that step's new logic that
doesn't need api.py locally first, to catch what's catchable before
trusting the rest to CI once the account's abuse-review lifts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:52:54 +05:00

427 lines
14 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
set -e
set -o pipefail
MIRROR_URL="https://api.savsis.xyz/git/mbs-panel.git/"
REPO_URL="https://github.com/devsavsis/mbs-panel.git"
APP_DIR="/opt/mbs-panel"
WEBROOT="/var/www/certbot"
retry() {
local n=1 max=3 delay=5
until "$@"; do
if [ "$n" -ge "$max" ]; then
echo "команда не прошла после $max попыток: $*"
return 1
fi
echo "попытка $n не прошла, повтор через ${delay}с..."
n=$((n + 1))
sleep "$delay"
done
}
if [ "$(id -u)" != "0" ]; then
echo "запусти от root: sudo bash install.sh"
exit 1
fi
if [ -t 0 ]; then
READ_TTY="/dev/tty"
else
READ_TTY="/dev/stdin"
fi
ask() {
local prompt="$1" default="$2" var
read -r -p "$prompt${default:+ [$default]}: " var < "$READ_TTY"
echo "${var:-$default}"
}
echo "== MBS Panel — установка =="
echo
. /etc/os-release
case "$ID" in
ubuntu) [ "${VERSION_ID%%.*}" -lt 22 ] && echo "поддерживается Ubuntu 22.04+, но пробуем всё равно" ;;
debian) [ "${VERSION_ID%%.*}" -lt 11 ] && echo "поддерживается Debian 11+, но пробуем всё равно" ;;
*) echo "тестировалось на Ubuntu 22/24 и Debian 11/12, но пробуем всё равно на $PRETTY_NAME" ;;
esac
BRAND_NAME=$(ask "Название твоего сервиса (видят клиенты — сайт/бот/подписка)" "MBS Panel")
PANEL_DOMAIN=$(ask "Домен панели (админка)" "")
SUB_DOMAIN=$(ask "Домен подписок" "")
SITE_DOMAIN=$(ask "Домен сайта (для CORS и ссылок в боте)" "$PANEL_DOMAIN")
DE1_ADDRESS=$(ask "Домен этой же ноды (для VPN-клиентов, отдельный A-record)" "de1.$SITE_DOMAIN")
BOT_TOKEN=$(ask "Токен бота (от @BotFather)" "")
BOT_USERNAME=$(ask "Юзернейм бота (без @, с окончанием _bot/_robot)" "")
ADMIN_IDS=$(ask "Telegram ID админов через запятую" "")
REALITY_SNI=$(ask "SNI-маскировка для Reality (любой крупный сайт с TLS1.3)" "www.microsoft.com")
if [ -z "$PANEL_DOMAIN" ] || [ -z "$SUB_DOMAIN" ] || [ -z "$BOT_TOKEN" ] || [ -z "$ADMIN_IDS" ]; then
echo "домен панели, домен подписок, токен бота и ID админов — обязательны"
exit 1
fi
ADMIN_PANEL_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 14) || true
echo
echo "ставим пакеты..."
export DEBIAN_FRONTEND=noninteractive
retry apt-get update -qq
retry apt-get install -y -qq curl wget git openssl ufw fail2ban \
python3 python3-venv python3-pip \
nginx-full certbot > /dev/null
install_xray() {
bash -c "$(curl -Ls https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
}
if [ ! -f /usr/local/bin/xray ]; then
echo "ставим Xray-core..."
retry install_xray
fi
echo "клонируем репозиторий в $APP_DIR..."
if [ -d "$APP_DIR/.git" ]; then
retry git -C "$APP_DIR" pull --quiet
else
if ! git clone --quiet "$MIRROR_URL" "$APP_DIR" 2>/dev/null; then
echo "зеркало недоступно, клонирую напрямую с GitHub..."
retry git clone --quiet "$REPO_URL" "$APP_DIR"
git -C "$APP_DIR" remote set-url origin "$MIRROR_URL"
fi
git -C "$APP_DIR" remote add github "$REPO_URL" 2>/dev/null || true
fi
cd "$APP_DIR"
python3 -m venv venv
retry venv/bin/pip install --quiet --upgrade pip
retry venv/bin/pip install --quiet -r requirements.txt
echo "генерируем Reality-ключи..."
KEYS=$(/usr/local/bin/xray x25519)
XRAY_PRIVATE_KEY=$(echo "$KEYS" | grep -i "Private" | awk '{print $NF}')
XRAY_PUBLIC_KEY=$(echo "$KEYS" | grep -i "Password\|Public" | awk '{print $NF}')
XRAY_SHORT_ID_TCP=$(openssl rand -hex 8)
XRAY_SHORT_ID_GRPC=$(openssl rand -hex 8)
XRAY_SHORT_ID_XHTTP=$(openssl rand -hex 8)
cat > "$APP_DIR/.env" << ENVEOF
BRAND_NAME=$BRAND_NAME
BOT_TOKEN=$BOT_TOKEN
BOT_USERNAME=$BOT_USERNAME
ADMIN_IDS=$ADMIN_IDS
ADMIN_PANEL_PASSWORD=$ADMIN_PANEL_PASSWORD
PANEL_DOMAIN=$PANEL_DOMAIN
SUB_DOMAIN=$SUB_DOMAIN
SITE_DOMAIN=$SITE_DOMAIN
XRAY_PUBLIC_KEY=$XRAY_PUBLIC_KEY
REALITY_SNI=$REALITY_SNI
XRAY_SHORT_ID_TCP=$XRAY_SHORT_ID_TCP
XRAY_SHORT_ID_GRPC=$XRAY_SHORT_ID_GRPC
XRAY_SHORT_ID_XHTTP=$XRAY_SHORT_ID_XHTTP
DE1_ADDRESS=$DE1_ADDRESS
ENVEOF
chmod 600 "$APP_DIR/.env"
echo "открываем порт 80 для выпуска сертификатов..."
mkdir -p "$WEBROOT"
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
rm -f /etc/nginx/sites-enabled/default
cat > /etc/nginx/sites-available/mbs-http80.conf << NGINXEOF
server {
listen 80;
listen [::]:80;
server_name $PANEL_DOMAIN $SUB_DOMAIN $DE1_ADDRESS;
location /.well-known/acme-challenge/ {
root $WEBROOT;
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
location /.well-known/acme-challenge/ {
root $WEBROOT;
}
location / {
return 404;
}
}
NGINXEOF
ln -sf /etc/nginx/sites-available/mbs-http80.conf /etc/nginx/sites-enabled/mbs-http80.conf
nginx -t && systemctl enable --now nginx && systemctl reload nginx
echo "выпускаем сертификаты..."
retry certbot certonly --webroot -w "$WEBROOT" --non-interactive --agree-tos \
--register-unsafely-without-email -d "$PANEL_DOMAIN" -d "$SUB_DOMAIN"
retry certbot certonly --webroot -w "$WEBROOT" --non-interactive --agree-tos \
--register-unsafely-without-email -d "$DE1_ADDRESS"
echo "готовлю серт для xray (он не root, letsencrypt/live ему не почитать)..."
mkdir -p /etc/xray/certs
cp "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem" /etc/xray/certs/de1.crt
cp "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem" /etc/xray/certs/de1.key
chmod 644 /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
chown nobody:nogroup /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > /etc/letsencrypt/renewal-hooks/deploy/mbs-reload.sh << HOOKEOF
#!/bin/bash
if [ -d "/etc/letsencrypt/live/$DE1_ADDRESS" ]; then
cp "/etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem" /etc/xray/certs/de1.crt
cp "/etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem" /etc/xray/certs/de1.key
chmod 644 /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
chown nobody:nogroup /etc/xray/certs/de1.crt /etc/xray/certs/de1.key
fi
systemctl reload nginx || true
systemctl restart xray || true
HOOKEOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mbs-reload.sh
echo "настраиваем nginx (SNI-роутер + бэкенды)..."
mkdir -p /etc/nginx/stream.d
if ! grep -q "^stream {" /etc/nginx/nginx.conf; then
cat >> /etc/nginx/nginx.conf << 'STREAMEOF'
stream {
include /etc/nginx/stream.d/*.conf;
}
STREAMEOF
fi
cat > /etc/nginx/stream.d/mbs.conf << STREAMCONFEOF
map \$ssl_preread_server_name \$mbs_backend {
$PANEL_DOMAIN web_backend;
$SUB_DOMAIN web_backend;
$DE1_ADDRESS web_backend;
default xray_reality;
}
upstream xray_reality { server 127.0.0.1:10443; }
upstream web_backend { server 127.0.0.1:8443; }
server {
listen 443 reuseport;
listen [::]:443 reuseport;
proxy_pass \$mbs_backend;
proxy_protocol on;
ssl_preread on;
}
STREAMCONFEOF
cat > /etc/nginx/conf.d/mbs-ratelimit.conf << 'RLEOF'
limit_req_zone $binary_remote_addr zone=mbs_login:10m rate=5r/m;
RLEOF
cat > /etc/nginx/sites-available/mbs-https.conf << HTTPSEOF
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
server_name $PANEL_DOMAIN;
ssl_certificate /etc/letsencrypt/live/$PANEL_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$PANEL_DOMAIN/privkey.pem;
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "same-origin" always;
location /admin/api/login {
limit_req zone=mbs_login burst=3 nodelay;
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
}
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
server_name $SUB_DOMAIN;
ssl_certificate /etc/letsencrypt/live/$PANEL_DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$PANEL_DOMAIN/privkey.pem;
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "same-origin" always;
location / {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
}
}
server {
listen 127.0.0.1:8443 ssl proxy_protocol;
server_name $DE1_ADDRESS;
ssl_certificate /etc/letsencrypt/live/$DE1_ADDRESS/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DE1_ADDRESS/privkey.pem;
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;
location / {
return 204;
}
}
HTTPSEOF
ln -sf /etc/nginx/sites-available/mbs-https.conf /etc/nginx/sites-enabled/mbs-https.conf
echo "пишем конфиг Xray..."
mkdir -p /usr/local/etc/xray
cat > /usr/local/etc/xray/config.json << XRAYEOF
{
"log": { "loglevel": "warning" },
"api": { "tag": "api", "services": ["HandlerService", "LoggerService", "StatsService"] },
"stats": {},
"policy": {
"levels": { "0": { "statsUserUplink": true, "statsUserDownlink": true } },
"system": {
"statsInboundUplink": true, "statsInboundDownlink": true,
"statsOutboundUplink": true, "statsOutboundDownlink": true
}
},
"routing": {
"rules": [ { "type": "field", "inboundTag": ["api"], "outboundTag": "api" } ]
},
"inbounds": [
{
"tag": "api", "listen": "127.0.0.1", "port": 10085,
"protocol": "dokodemo-door", "settings": { "address": "127.0.0.1" }
},
{
"tag": "vless-tcp-reality", "listen": "127.0.0.1", "port": 10443,
"protocol": "vless",
"settings": { "clients": [], "decryption": "none" },
"sniffing": { "enabled": true, "destOverride": ["http", "tls"] },
"streamSettings": {
"network": "tcp", "security": "reality",
"realitySettings": {
"show": false, "dest": "$REALITY_SNI:443", "xver": 1,
"serverNames": ["$REALITY_SNI"],
"privateKey": "$XRAY_PRIVATE_KEY",
"shortIds": ["$XRAY_SHORT_ID_TCP"]
},
"sockopt": { "acceptProxyProtocol": true }
}
},
{
"tag": "vless-grpc-reality", "listen": "0.0.0.0", "port": 2053,
"protocol": "vless",
"settings": { "clients": [], "decryption": "none" },
"sniffing": { "enabled": true, "destOverride": ["http", "tls"] },
"streamSettings": {
"network": "grpc", "security": "reality",
"realitySettings": {
"show": false, "dest": "$REALITY_SNI:443", "xver": 0,
"serverNames": ["$REALITY_SNI"],
"privateKey": "$XRAY_PRIVATE_KEY",
"shortIds": ["$XRAY_SHORT_ID_GRPC"]
},
"grpcSettings": { "serviceName": "mbs-grpc" }
}
},
{
"tag": "vless-xhttp-reality", "listen": "0.0.0.0", "port": 2087,
"protocol": "vless",
"settings": { "clients": [], "decryption": "none" },
"sniffing": { "enabled": true, "destOverride": ["http", "tls"] },
"streamSettings": {
"network": "xhttp", "security": "reality",
"realitySettings": {
"show": false, "dest": "$REALITY_SNI:443", "xver": 0,
"serverNames": ["$REALITY_SNI"],
"privateKey": "$XRAY_PRIVATE_KEY",
"shortIds": ["$XRAY_SHORT_ID_XHTTP"]
},
"xhttpSettings": { "path": "/mbs-xh", "mode": "auto" }
}
},
{
"tag": "vless-ws-tls", "listen": "0.0.0.0", "port": 8880,
"protocol": "vless",
"settings": { "clients": [], "decryption": "none" },
"sniffing": { "enabled": true, "destOverride": ["http", "tls"] },
"streamSettings": {
"network": "ws", "security": "tls",
"wsSettings": { "path": "/mbs-ws" },
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/xray/certs/de1.crt",
"keyFile": "/etc/xray/certs/de1.key"
}]
}
}
}
],
"outbounds": [
{ "protocol": "freedom", "tag": "direct" },
{ "protocol": "blackhole", "tag": "block" }
]
}
XRAYEOF
echo "systemd-юниты..."
CPU_COUNT=$(nproc 2>/dev/null || echo 1)
if [ "$CPU_COUNT" -lt 2 ]; then API_WORKERS=1
elif [ "$CPU_COUNT" -gt 4 ]; then API_WORKERS=4
else API_WORKERS=$CPU_COUNT
fi
cp "$APP_DIR/systemd/mbs-bot.service" /etc/systemd/system/mbs-bot.service
sed "s/__WORKERS__/$API_WORKERS/" "$APP_DIR/systemd/mbs-api.service" > /etc/systemd/system/mbs-api.service
systemctl daemon-reload
echo "ставим CLI mbs..."
cp "$APP_DIR/mbs" /usr/local/bin/mbs
chmod +x /usr/local/bin/mbs
echo "фаервол..."
ufw allow 22/tcp > /dev/null || true
ufw allow 80/tcp > /dev/null || true
ufw allow 443/tcp > /dev/null || true
ufw allow 2053/tcp > /dev/null || true
ufw allow 2087/tcp > /dev/null || true
ufw allow 8880/tcp > /dev/null || true
ufw --force enable > /dev/null || true
systemctl enable --now fail2ban > /dev/null
nginx -t
systemctl reload nginx
systemctl enable --now xray
systemctl enable --now mbs-bot
systemctl enable --now mbs-api
sleep 2
if [ -z "$MBS_SKIP_STATS" ]; then
curl -s -m 5 -X POST https://stats.api.savsis.xyz/install \
-H "Content-Type: application/json" -d "{\"os\":\"$ID\"}" > /dev/null 2>&1 || true
fi
echo
echo "== готово =="
echo "Панель: https://$PANEL_DOMAIN"
echo "Пароль: $ADMIN_PANEL_PASSWORD (сменить: mbs pass)"
echo "Подписки: https://$SUB_DOMAIN"
echo "Сайт: https://$SUB_DOMAIN (готовый лендинг, название/тарифы уже подставлены — правь site/index.html под себя, если нужно)"
echo "Нода: $DE1_ADDRESS"
echo
echo "статус сервисов:"
mbs status || true