Сервис / работает
ShadowNet VPN
Telegram-бот, сайт с личным кабинетом и оплатой, свой бэкенд над панелью серверов и собственные приложения, которые сейчас в разработке.

Как устроено
Бот и сайт работают поверх одного бэкенда с общей бизнес-логикой. Серверная панель только исполняет команды: подписки, деньги и трафик хранятся в своей базе, поэтому панель можно сменить, не потеряв ни одного клиента.
Бот
С него начинается большинство клиентов. Нажал /start — получил 3 дня бесплатно и ключ с QR-кодом, подключился за минуту. Дальше в боте оплата, продление, подарки и поддержка.
- Оплата картой, через СБП и криптовалютой, внутренний кошелёк
- Подарочные подписки, промокоды и акции для вернувшихся
- Реферальная программа с выводом заработка на карту
- Поддержка в группе с темами, синхронно с чатом на сайте
Нажми /start, чтобы получить 3 дня свободного интернета бесплатно.
/start20:52
Привет! Это ShadowNet. Рады, что вы здесь.
Мы делаем интернет доступным и безопасным без лишних заморочек. Никаких переплат, никаких сложных настроек. Подключились — и пользуетесь.
Хотите попробовать?
Есть бесплатный пробный период на 3 дня, а потом подписка почти даром.
Просто нажмите кнопку ниже — и всё полетит20:52
Сайт и кабинет
Лендинг и личный кабинет в одном приложении. Вход по почте, через Google или Telegram, и аккаунты склеиваются: оплатил на сайте — подписка видна и в боте.
В кабинете оплата и история заказов, подарки, дерево рефералов, живой чат поддержки, а для себя — админка на два десятка страниц. Страницы с ценами и ответами собираются заранее, чтобы сайт открывался быстро и хорошо индексировался.


Изнутри
Так выглядит кабинет после входа: срок подписки, сколько трафика осталось, какие серверы сейчас работают и какие устройства подключены.
Отсюда же подключают ещё одно устройство, докупают места или перевыпускают ключ, вводят промокод. Поддержка — кнопкой в углу, живым чатом.

Одна почта, два аккаунта
ivan@mail.ru
ivan@mail.ru
Почта хранилась так, как её ввели, а искалась точным совпадением. Аккаунт Ivan@Mail.ru не находился по ivan@mail.ru: вход и «Забыли пароль» молча не работали.
А из-за пробела в конце адреса у одного человека завелось два аккаунта: на одном подписка, на другом ничего. Теперь у почты один канон — без пробелов и в нижнем регистре, одинаковый в коде и в базе.
Маршрута уже нет — значит, всё хорошо
При выключении VPN клиент снимает свои маршруты. Бывает, что Windows уже убрала маршрут сама или пропал весь адаптер. Если считать это ошибкой, выключение падает на ровном месте и человек видит красное сообщение.
Поэтому «такого нет» при снятии — не отказ, а цель, достигнутая раньше нас. Решение записано отдельно, в журнале оно под номером 035.
Касса молчит
Если оплаты перестали проходить, об этом нужно узнать раньше клиентов. Раз в 15 минут бот проверяет кассу: несколько неудачных попыток подряд или сутки без единой оплаты — и в Telegram уходит алерт.
Пока касса молчит, алерт повторяется раз в 6 часов, а не каждые 15 минут, поэтому его не перестают читать. Отметка об алерте хранится в базе: перезапуск бота его не теряет и не дублирует. Как только оплата проходит, приходит сообщение, что касса снова работает.
Свои ключи в чужих списках
Бесплатные VPN-конфиги собирают в публичные списки на GitHub и обновляют каждые пару часов. Если туда попал сервер проекта или ключ клиента, значит, кто-то раздаёт доступ бесплатно.
Отдельный сервис обходит такие списки, разбирает конфиги и сверяет их с инвентарём проекта: адрес ноды, публичный ключ, UUID клиента. Совпал UUID — видно, какой аккаунт слил. Сами UUID в инвентаре лежат только хешами.
services/database/users.py, migrations.py
def norm_email(email: str | None) -> str | None: """Канонический вид e-mail: без краевых пробелов, в нижнем регистре. Уникальность в БД держит функциональный индекс idx_users_email_lower, поэтому и хранить, и искать надо в одном регистре. До этой нормализации точечный поиск (email = $1) расходился с индексом: аккаунт, сохранённый как Ivan@Mail.ru, не находился по ivan@mail.ru — вход и сброс пароля молча не работали. """ return email.strip().lower() if email else email... await pool.execute( "CREATE INDEX IF NOT EXISTS idx_users_email_canon " "ON users (LOWER(BTRIM(email))) WHERE email IS NOT NULL" )
core/internal/tun/netop.go, cleanup_windows.go
var ( // errNotFound — такого маршрута или адреса нет (ERROR_NOT_FOUND, // ERROR_FILE_NOT_FOUND). Для снятия это не отказ, а цель, достигнутая // раньше нас. errNotFound = errors.New("такого нет") // errNoInterface — интерфейса с таким именем или номером в системе нет. errNoInterface = errors.New("интерфейса нет"))// absent отвечает, значит ли отказ «снимать нечего»: ни объекта, ни// интерфейса, на котором он стоял.func absent(err error) bool { return errors.Is(err, errNotFound) || errors.Is(err, errNoInterface)}...for _, r := range before { // Исчезнувший между вопросом и снятием — цель достигнута (absent). if err := do(ctx, routeOp{prefix: r.prefix, nextHop: r.nextHop, iface: r.iface}); err != nil && !absent(err) { errs = append(errs, err) }}
services/scheduler.py
# Пока касса молчит — напоминать не чаще, чем раз в 6 ч: сбой, который не починили за# ночь, утром должен быть виден снова, а лента из одинаковых сообщений раз в 15 минут# приучает их не читать (так и вышло со старым правилом analytics_bot).PAYMENT_SILENCE_REMIND_HOURS = 6async def watch_payment_silence(bot: Bot): ... now = datetime.now(timezone.utc) silent = s["streak_alert"] or s["day_silence"] if silent: if state is None: if await send_alert(bot, _payment_silence_text(s, now, None, links)): await db.set_setting(_PAYMENT_SILENCE_KEY, json.dumps( {"since": now.isoformat(), "sent_at": now.isoformat()})) elif now - sent_at >= timedelta(hours=PAYMENT_SILENCE_REMIND_HOURS): if await send_alert(bot, _payment_silence_text(s, now, since, links)): await db.set_setting(_PAYMENT_SILENCE_KEY, json.dumps( {"since": since.isoformat(), "sent_at": now.isoformat()})) return if state is not None: ... if await send_alert(bot, text): await db.delete_setting(_PAYMENT_SILENCE_KEY)
leakwatch/matcher.py
"""Сопоставление чужих конфигов с нашим инвентарём.Уровни находок: L1 — наш IP (нода в публичном обороте); L2 — наш домен/SNI; L3 — reality public key (доказательство, что утёк именно наш inbound); L4 — UUID нашего клиента (видно, кто именно слил); L5 — упоминание наших доменов в тексте (ссылка на подписку, README и т.п.)."""...def lookup_client(uuid: str, inv: dict) -> dict: """Кто в панели стоит за UUID из конфига: {user_id, panel_id, status} или {}.""" client = inv["uuid_index"].get(inventory.hash_uuid(uuid)) if uuid else None if not client: return {} return client if isinstance(client, dict) else {"user_id": client}... if e.pbk and e.pbk in inv["pbks"]: matched_here.append(Finding( "L3", "high", e.pbk, "reality public key нашего inbound", source, path, e.host, e.remark, ))
Фрагменты из репозиториев, сокращены
От /start до подключения
/start
Привет! Это ShadowNet. Чтобы продолжить, подпишитесь на канал новостей.
Подписаться
@router.message(CommandStart())
async def start(msg, deep_link):
user = await users.get_or_create(msg.from_user.id)
await marks.apply(user, deep_link) # подарок, промокод, приглашение
пользователь зарегистрирован
Я подписался
Готово! Можно взять пробный период: 3 дня и 5 ГБ, без карты.
Попробовать бесплатно
member = await bot.get_chat_member(NEWS_CHANNEL, user.tg_id)
if member.status in ("left", "kicked"):
return await ask_to_subscribe(msg)
подписка на канал есть
Попробовать бесплатно
Пробный период включён на 3 дня.
profile = await panel.create_user(user.id, days=3, traffic_gb=5) await db.save_subscription(user.id, profile.uuid, trial=True)
профиль создан в панели
Подключиться
url = panel.subscription_url(profile) await msg.answer_photo(qr(url), reply_markup=connect_button(url))
ключ отправлен
Подключено
# нода приняла соединение, бот следит за трафиком
if await traffic_since(user, hours=1) == 0:
await remind_how_to_connect(user)
трафик пошёл
Оформить подписку · 200 ₽
Оплата прошла. Подписка продлена на месяц.
order = await orders.create(user.id, tariff="1m") # вебхук или поллер: paid → fulfilling → fulfilled await panel.extend(profile.uuid, days=30)
подписка продлена
В цифрах
- 16
- серверов в управлении: ноды, сайт, бэкенд и мониторинг
- 1 114
- коммитов в боте, сайте, приложениях и инструментах с конца января
- ~140
- маршрутов API у бэкенда, на котором живут бот и сайт
- 280+
- файлов с автотестами, тесты гоняются в CI на чистой базе
- 3 дня пробно
- до 4 устройств
- 2 платёжных шлюза
- ~25 фоновых задач
- 18 модулей админки
- Windows / Android
Нужен бот, сервис или сайт? Опишите задачу, отвечу в течение дня.