feat: Zero-Exposure Pelmeni Shield VPN Deployment Stack

This commit is contained in:
Alexander Tyrin 2026-10-02 17:07:30 +04:00
commit 4b361226da
8 changed files with 691 additions and 0 deletions

14
.env.example Normal file
View file

@ -0,0 +1,14 @@
# Доменное имя вашего VPN сервера (должно резолвиться на IP хоста!)
DOMAIN=endpoint.pelmeni-club.com
# Контактный email для регистрации сертификатов Let's Encrypt
CERTBOT_EMAIL=admin@pelmeni-club.com
# Секретный токен для выдачи подписок через Nginx
# Ссылка на подписку будет: https://${DOMAIN}/api/v1/recipes/secret_${SUB_TOKEN}/${CLIENT_SUB_ID}
SUB_TOKEN=9f8e7d6c5b4a3210
# Telegram-оповещения о сканировании РКН (опционально)
TELEGRAM_BOT_TOKEN=8065539545:AAECBnyjinUVVGv3vhtoWZ99tUmnT9sze-k
TELEGRAM_CHAT_ID=-1004488599816
TELEGRAM_TOPIC_ID=18

55
README.md Normal file
View file

@ -0,0 +1,55 @@
# 🥟 Pelmeni Shield VPN: Zero-Exposure Deployment Stack
> **Архитектурная концепция:** Полная маскировка VPN-узла под легальный кулинарный портал ценителей сибирских пельменей с нулевым открытием служебных портов наружу и активным отпором Active Probing со стороны РКН / ЦМУ ССОП.
---
## 🎯 Ключевые принципы безопасности
1. **Zero-Exposure (Только порты 80 и 443 наружу):**
- Порт `2053` панели 3X-UI привязан **СТРОГО к `127.0.0.1`** (доступ для сисадмина только через SSH-туннель).
- Порт подписок **НЕ выставлен наружу отдельным портом**. Выдача идет через Nginx по секретному пути `https://${DOMAIN}/api/v1/recipes/secret_${SUB_TOKEN}/${CLIENT_SUB_ID}`.
2. **Легитимный SSL Let's Encrypt вместо чужого SNI:**
- Выпускается реальный сертификат на ваш домен/поддомен.
- Сканеры Censys, Shodan и РКН видят 100% совпадение IP, PTR и SAN сертификата.
3. **Бесшовный Fallback на сайт любителей пельменей:**
- Все входящие подключения, не являющиеся валидным VLESS-клиентом (боты, сканеры, веб-браузеры), прозрачно сбрасываются Xray на внутренний Nginx (`127.0.0.1:8080`), который отдает полноценный сайт с рецептами и форумом.
4. **Активный отпор Active Probing:**
- Официальная подсеть сканеров РКН/ГРЧЦ (`212.192.156.0/22`, AS61280) жестко блокируется в `iptables` с отправкой статистики в Telegram топик 18 супергруппы «Майа-Штаб».
---
## 🚀 Быстрый старт на новом VPS
### 1. Подготовка домена
Направьте DNS A-запись вашего домена (например, `vpn.pelmeni-club.com`) на публичный IP вашего VPS.
### 2. Клонирование и настройка
```bash
git clone http://192.168.10.105:3010/matrixhasyou/pelmeni-shield-vpn.git
cd pelmeni-shield-vpn
# Скопируйте и настройте переменные окружения:
cp .env.example .env
nano .env # Укажите DOMAIN, CERTBOT_EMAIL и SUB_TOKEN
```
### 3. Автоматический запуск
```bash
bash scripts/init.sh
```
### 4. Вход в панель 3X-UI через безопасный SSH-туннель
```bash
# На вашем ПК:
ssh -L 2053:127.0.0.1:2053 root@IP_СЕРВЕРА
# В браузере:
http://localhost:2053
```
### 5. Мониторинг пробинга РКН (Cron)
```bash
# Запуск каждые 15 минут в crontab:
*/15 * * * * python3 /path/to/pelmeni-shield-vpn/scripts/rkn_probe_monitor.py >/dev/null 2>&1
```

60
docker-compose.yml Normal file
View file

@ -0,0 +1,60 @@
version: "3.8"
services:
# 1. 3X-UI: Xray-core движок + скрытая веб-панель
3x-ui:
image: ghcr.io/mhsanaei/3x-ui:latest
container_name: 3x-ui-shield
restart: always
environment:
- XRAY_VMESS_AEAD_FORCED=false
volumes:
- ./3x-ui:/etc/x-ui/
- ./certbot/certs:/root/cert/
ports:
- "443:443" # Xray VLESS (единственный открытый защищенный порт)
- "127.0.0.1:2053:2053" # Панель 3X-UI СТРОГО на localhost (доступ через SSH-туннель!)
networks:
- shield_net
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# 2. Nginx Decoy: сайт клуба любителей пельменей + скрытый шлюз подписок
nginx-decoy:
image: nginx:alpine
container_name: nginx-decoy
restart: always
ports:
- "80:80" # HTTP для ACME certbot и редиректа на HTTPS
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/site:/usr/share/nginx/html:ro
- ./certbot/www:/var/www/certbot:ro
networks:
- shield_net
depends_on:
- 3x-ui
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# 3. Certbot: автоматический выпуск и продление SSL Let's Encrypt
certbot:
image: certbot/certbot:latest
container_name: certbot-ssl
volumes:
- ./certbot/certs:/etc/letsencrypt/
- ./certbot/www:/var/www/certbot/
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12d & wait $${!}; done;'"
networks:
- shield_net
networks:
shield_net:
name: shield_net
driver: bridge

47
nginx/conf.d/default.conf Normal file
View file

@ -0,0 +1,47 @@
# ========================================================
# Pelmeni Shield Nginx Configuration
# ========================================================
# 1. Порт 80: Let's Encrypt ACME Challenge + Редирект на HTTPS
server {
listen 80;
server_name _;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# 2. Порт 8080: Внутренний Fallback для Xray (Сеть shield_net)
server {
listen 8080 default_server;
server_name _;
# Корневой сайт: Клуб любителей сибирских пельменей
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Секретный шлюз выдачи подписок 3X-UI
# Снаружи выглядит как вызов API рецептов:
# URL: https://DOMAIN/api/v1/recipes/secret_TOKEN/SUB_ID
location ~ ^/api/v1/recipes/secret_([a-zA-Z0-9]+)/(.*)$ {
proxy_pass http://3x-ui:2053/sub/$2;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_connect_timeout 5s;
proxy_read_timeout 15s;
}
# Защита от перебора: любая ошибка выдает главную страницу пельменей
error_page 404 /index.html;
error_page 500 502 503 504 /index.html;
}

91
nginx/site/index.html Normal file
View file

@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Клуб любителей сибирских пельменей — Традиции, Рецепты и Форум мастеров</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="header">
<div class="header-inner">
<div class="logo">
<span class="logo-icon">🥟</span>
<div class="logo-text">
<span class="brand">СИБИРСКИЙ ПЕЛЬМЕНЬ</span>
<span class="tagline">Всероссийское сообщество ценителей традиционной лепки</span>
</div>
</div>
<nav class="nav">
<a href="#recipes">Рецепты теста</a>
<a href="#fillings">Фарш и начинки</a>
<a href="#secrets">Секреты лепки</a>
<a href="#forum">Форум ценителей</a>
<a href="#contacts">О клубе</a>
</nav>
</div>
</header>
<main class="main-content">
<section class="hero">
<div class="hero-badge">Традиционный сибирский рецепт 1889 года</div>
<h1>Настоящий пельмень любит мороз, крепкое тесто и три вида мяса</h1>
<p>Наш клуб объединяет более 14 000 кулинаров и любителей пельменного дела по всей стране. Делимся секретами правильного замеса, выдерживания фарша на колотом льду и тонкостями подачи.</p>
</section>
<section class="grid-features" id="recipes">
<div class="card">
<div class="card-num">01</div>
<h3>Идеальное тесто на ледяной воде</h3>
<p>Мука высшего сорта из твердых сортов пшеницы, холодная колодезная вода, щепотка соли и домашнее яйцо. Главное правило — вымешивать не менее 20 минут до абсолютной эластичности и дать отдохнуть 40 минут под влажным полотенцем.</p>
</div>
<div class="card">
<div class="card-num">02</div>
<h3>Триединство начинки</h3>
<p>Канонический сибирский сбор: 45% отборной говядины, 35% сочной свинины и 20% нежной баранины (или дичи). Мелко рубленный репчатый лук, черный свежемолотый перец и немного ледяного мясного бульона для невероятной сочности.</p>
</div>
<div class="card">
<div class="card-num">03</div>
<h3>Лепка и быстрая заморозка</h3>
<p>Толщина раскатки сочня — ровно 1.5 мм. Края защипываются «косичкой» или классическим «ушком». Настоящие пельмени после лепки обязательно выставляются на сухой сибирский мороз или в шоковую заморозку при -24°C.</p>
</div>
</section>
<section class="forum-preview" id="forum">
<h2>Свежие темы клуба и обсуждения мастеров</h2>
<div class="forum-topics">
<div class="topic">
<span class="topic-tag">ТЕХНОЛОГИЯ</span>
<div class="topic-info">
<h4>Тестораскаточная машинка vs скалка из сибирской лиственницы: битва мнений</h4>
<span class="topic-meta">Автор: Дед_Матвей • 48 ответов • Последнее: 2 часа назад</span>
</div>
</div>
<div class="topic">
<span class="topic-tag">СОУСЫ</span>
<div class="topic-info">
<h4>Классический уксус 6% с толченым чесноком или деревенская сметана с укропом?</h4>
<span class="topic-meta">Автор: Сибирячка_Ольга • 112 ответов • Последнее: 15 минут назад</span>
</div>
</div>
<div class="topic">
<span class="topic-tag">ИСТОРИЯ</span>
<div class="topic-info">
<h4>Пельмени в походах Ермака: почему замороженные мешки спасали от цинги</h4>
<span class="topic-meta">Автор: Краевед_Игорь • 34 ответа • Последнее: Вчера</span>
</div>
</div>
</div>
</section>
</main>
<footer class="footer" id="contacts">
<div class="footer-inner">
<div>&copy; 2026 Некоммерческое содружество «Сибирский Пельмень». Все права защищены.</div>
<div>Материалы клуба доступны для свободного кулинарного использования.</div>
</div>
</footer>
</body>
</html>

214
nginx/site/style.css Normal file
View file

@ -0,0 +1,214 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #f7f6f2;
color: #2b2b2b;
line-height: 1.6;
}
.header {
background: #ffffff;
border-bottom: 2px solid #e2ded5;
padding: 16px 24px;
}
.header-inner {
max-width: 1100px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
font-size: 32px;
}
.logo-text {
display: flex;
flex-direction: column;
}
.brand {
font-weight: 800;
font-size: 18px;
letter-spacing: 1px;
color: #8b2500;
}
.tagline {
font-size: 11px;
color: #777777;
}
.nav {
display: flex;
gap: 20px;
}
.nav a {
text-decoration: none;
color: #444444;
font-size: 14px;
font-weight: 500;
transition: color 0.2s;
}
.nav a:hover {
color: #8b2500;
}
.main-content {
max-width: 1100px;
margin: 40px auto;
padding: 0 20px;
}
.hero {
background: #ffffff;
border: 1px solid #e0dbce;
border-left: 6px solid #8b2500;
padding: 36px;
margin-bottom: 36px;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
}
.hero-badge {
display: inline-block;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
color: #8b2500;
background: #fcf1ed;
padding: 4px 10px;
margin-bottom: 12px;
}
.hero h1 {
font-size: 28px;
font-weight: 700;
color: #222222;
margin-bottom: 16px;
}
.hero p {
font-size: 16px;
color: #555555;
max-width: 800px;
}
.grid-features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 40px;
}
.card {
background: #ffffff;
border: 1px solid #e0dbce;
padding: 24px;
position: relative;
}
.card-num {
font-size: 24px;
font-weight: 900;
color: #e5ded5;
position: absolute;
top: 16px;
right: 20px;
}
.card h3 {
font-size: 18px;
color: #8b2500;
margin-bottom: 12px;
padding-right: 30px;
}
.card p {
font-size: 14px;
color: #666666;
}
.forum-preview {
background: #ffffff;
border: 1px solid #e0dbce;
padding: 30px;
}
.forum-preview h2 {
font-size: 20px;
color: #222222;
border-bottom: 2px solid #f0ece3;
padding-bottom: 12px;
margin-bottom: 20px;
}
.forum-topics {
display: flex;
flex-direction: column;
gap: 14px;
}
.topic {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: #faf9f6;
border: 1px solid #ece7dc;
}
.topic-tag {
font-size: 10px;
font-weight: bold;
padding: 3px 8px;
background: #ece7dc;
color: #555555;
letter-spacing: 0.5px;
}
.topic-info h4 {
font-size: 15px;
color: #222222;
margin-bottom: 4px;
}
.topic-meta {
font-size: 12px;
color: #888888;
}
.footer {
border-top: 1px solid #e0dbce;
background: #ffffff;
padding: 24px;
font-size: 12px;
color: #888888;
margin-top: 60px;
}
.footer-inner {
max-width: 1100px;
margin: 0 auto;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}

79
scripts/init.sh Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# ==============================================================================
# Pelmeni Shield VPN - Initial Setup & Zero-Exposure Deployment Script
# ==============================================================================
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ ! -f .env ]]; then
if [[ -f .env.example ]]; then
echo "⚠️ Файл .env не найден! Создаю из .env.example..."
cp .env.example .env
echo "📝 Пожалуйста, отредактируйте .env (укажите ваш реальный DOMAIN) и запустите скрипт снова!"
exit 1
else
echo "❌ Ошибка: .env.example не найден!"
exit 1
fi
fi
# shellcheck source=/dev/null
source .env
if [[ -z "${DOMAIN:-}" || "${DOMAIN}" == "endpoint.pelmeni-club.com" ]]; then
echo "❌ Ошибка: Укажите реальный DOMAIN в файле .env!"
exit 1
fi
echo "🛡️ === 1. Активная защита: Дроп подсети сканеров РКН (ГРЧЦ / ЦМУ ССОП) ==="
RKN_SUBNET="212.192.156.0/22"
if command -v iptables >/dev/null 2>&1; then
if ! iptables -C INPUT -s "${RKN_SUBNET}" -j DROP 2>/dev/null; then
echo "🚫 Блокирую диапазон сканеров РКН (${RKN_SUBNET}) в iptables..."
iptables -I INPUT 1 -s "${RKN_SUBNET}" -m comment --comment "RKN_ACTIVE_PROBING_DROP" -j DROP
echo "✅ Правило iptables успешно добавлено."
else
echo "ℹ️ Правило для ${RKN_SUBNET} уже присутствует в iptables."
fi
else
echo "⚠️ iptables не найден, пропускаю настройку файрвола хоста."
fi
echo "📁 === 2. Подготовка каталогов ==="
mkdir -p 3x-ui certbot/certs certbot/www nginx/conf.d nginx/site
echo "🔐 === 3. Проверка SSL-сертификата для ${DOMAIN} ==="
CERT_PATH="certbot/certs/live/${DOMAIN}/fullchain.pem"
if [[ ! -f "${CERT_PATH}" ]]; then
echo "🌐 Запуск первичного выпуска SSL-сертификата Let's Encrypt..."
docker run -it --rm --name certbot-init \
-p 80:80 \
-v "$(pwd)/certbot/certs:/etc/letsencrypt" \
-v "$(pwd)/certbot/www:/var/www/certbot" \
certbot/certbot certonly --standalone \
--non-interactive --agree-tos \
--email "${CERTBOT_EMAIL}" \
-d "${DOMAIN}"
echo "✅ SSL-сертификат успешно получен!"
else
echo "✅ SSL-сертификат уже существует: ${CERT_PATH}"
fi
echo "🚀 === 4. Запуск контейнеров Pelmeni Shield ==="
docker compose up -d
echo ""
echo "=========================================================================="
echo "🎉 СТЕК PELMENI SHIELD VPN УСПЕШНО РАЗВЕРНУТ И ЗАЩИЩЕН!"
echo "=========================================================================="
echo "🥟 Сайт-заглушка: https://${DOMAIN}/"
echo "🥟 Внутренний Fallback: http://127.0.0.1:8080 (только внутри Docker)"
echo "🥟 Шлюз подписок: https://${DOMAIN}/api/v1/recipes/secret_${SUB_TOKEN}/<subId>"
echo ""
echo "🔒 ВЕБ-ПАНЕЛЬ 3X-UI ЗАКРЫТА ОТ МИРА И СЛУШАЕТ ТОЛЬКО 127.0.0.1:2053!"
echo "👉 Для входа в панель откройте безопасный SSH-туннель с вашего ПК:"
echo " ssh -L 2053:127.0.0.1:2053 root@$(curl -s ifconfig.me || echo 'IP_СЕРВЕРА')"
echo " Затем перейдите в браузере: http://localhost:2053"
echo "=========================================================================="

131
scripts/rkn_probe_monitor.py Executable file
View file

@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""
RKN Active Probing Monitor & Telegram Alerting
Отслеживает попытки сканирования со стороны подсетей РКН / ГРЧЦ (212.192.156.0/22)
и отправляет сводку в топик 18 супергруппы «Майа-Штаб» (-1004488599816).
"""
import os
import sys
import json
import socket
import urllib.request
import subprocess
from datetime import datetime
from pathlib import Path
# Конфигурация (читается из .env или переменных окружения)
ENV_PATH = Path(__file__).resolve().parent.parent / ".env"
config = {}
if ENV_PATH.exists():
with open(ENV_PATH, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
config[k.strip()] = v.strip().strip("'\"")
BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", config.get("TELEGRAM_BOT_TOKEN", "8065539545:AAECBnyjinUVVGv3vhtoWZ99tUmnT9sze-k"))
CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", config.get("TELEGRAM_CHAT_ID", "-1004488599816"))
TOPIC_ID = int(os.getenv("TELEGRAM_TOPIC_ID", config.get("TELEGRAM_TOPIC_ID", "18")))
STATE_FILE = Path("/tmp/rkn_probe_monitor_state.json")
RKN_COMMENT = "RKN_ACTIVE_PROBING_DROP"
def get_iptables_stats():
"""Считывает счетчики пакетов и байт из iptables для правила RKN."""
try:
cmd = ["iptables", "-L", "INPUT", "-v", "-n", "-x"]
res = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True)
for line in res.stdout.splitlines():
if RKN_COMMENT in line:
parts = line.strip().split()
if len(parts) >= 2:
packets = int(parts[0])
bytes_count = int(parts[1])
return packets, bytes_count
except Exception as e:
print(f"Ошибка чтения iptables: {e}", file=sys.stderr)
return None, None
def get_server_identity():
"""Возвращает имя хоста и внешний IP."""
hostname = socket.gethostname()
external_ip = "Unknown"
try:
req = urllib.request.Request("https://ifconfig.me/ip", headers={"User-Agent": "curl/7.68.0"})
with urllib.request.urlopen(req, timeout=5) as resp:
external_ip = resp.read().decode().strip()
except Exception:
pass
return hostname, external_ip
def send_telegram_alert(text):
"""Отправляет сообщение в топик супергруппы Телеграм."""
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
payload = {
"chat_id": CHAT_ID,
"text": text,
"parse_mode": "HTML",
"message_thread_id": TOPIC_ID
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
return data.get("ok", False)
except Exception as e:
print(f"Ошибка отправки в Telegram: {e}", file=sys.stderr)
return False
def main():
pkts, bytes_total = get_iptables_stats()
if pkts is None:
print("Правило iptables с меткой RKN_ACTIVE_PROBING_DROP не найдено.")
sys.exit(0)
# Читаем предыдущее состояние
prev_pkts = 0
prev_bytes = 0
if STATE_FILE.exists():
try:
with open(STATE_FILE, "r", encoding="utf-8") as f:
state = json.load(f)
prev_pkts = state.get("packets", 0)
prev_bytes = state.get("bytes", 0)
except Exception:
pass
delta_pkts = pkts - prev_pkts
delta_bytes = bytes_total - prev_bytes
# Сохраняем текущее состояние
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump({"packets": pkts, "bytes": bytes_total, "updated_at": datetime.now().isoformat()}, f)
if delta_pkts > 0:
hostname, ext_ip = get_server_identity()
domain = config.get("DOMAIN", hostname)
now_str = datetime.now().strftime("%d.%m.%Y %H:%M:%S")
msg = (
f"🚨 <b>[ACTIVE PROBING DETECTED] Попытка сканирования от РКН!</b>\n\n"
f"🌐 <b>Сервер:</b> <code>{domain}</code> ({ext_ip})\n"
f"🎯 <b>Источник:</b> AS61280 ФГУП «ГРЧЦ» (ЦМУ ССОП)\n"
f"🚫 <b>Подсеть сканера:</b> <code>212.192.156.0/22</code>\n\n"
f"📊 <b>Новых пакетов:</b> <code>+{delta_pkts}</code> ({delta_bytes / 1024:.1f} КБ)\n"
f"📈 <b>Всего заблокировано:</b> <code>{pkts}</code> пакетов ({bytes_total / 1024 / 1024:.2f} МБ)\n"
f"🛡️ <b>Результат:</b> Пакеты жестко сброшены в <b>DROP</b>. Сканер не получил ответа и считает хост мертвым.\n"
f"🕒 <i>{now_str}</i>"
)
print(f"Обнаружен пробинг: +{delta_pkts} пакетов! Отправка алерта...")
send_telegram_alert(msg)
else:
print(f"Новых попыток сканирования не зафиксировано. Всего дропнуто: {pkts} пакетов.")
if __name__ == "__main__":
main()