Region-aware pricing: GeoIP + VPN-детект + публичный /plans + бот
Build & Push Image / Build and push Caddy (Cloudflare DNS) image (push) Successful in 26s
Build & Push Image / Build and push to Gitea Registry (push) Successful in 9m36s
CI / fmt + clippy + test (push) Failing after 24m37s

- modules::geoip — DB-IP Lite (.mmdb, без регистрации/ключа, в отличие от
  MaxMind) для страны по IP, X4BNet lists_vpn для грубой VPN/датацентр-
  эвристики. Обе базы скачиваются в рантайме, обновление — фоновая
  задача в tasks.rs (раз в сутки по умолчанию, GEOIP_REFRESH_INTERVAL).
- GET /api/v1/billing/plans (public_router, без auth_guard) — цены в
  валюте, определённой по IP посетителя (X-Real-IP/X-Forwarded-For,
  см. nginx-конфиги). VPN/датацентр IP -> всегда базовый USD, чтобы
  обход региона через любой публичный VPN не обесценивал скидки.
- Бот: Bot API не отдаёт IP пользователя вообще — валюта определяется по
  Telegram language_code. Показ цены — в валюте региона, САМА оплата
  через CryptoBot остаётся в USD/USDT (провайдер биллит 1:1 к USD-цене
  тарифа, менять валюту списания нельзя, не меняя сам провайдер).
This commit is contained in:
2026-07-11 00:55:03 +07:00
parent a89721fbee
commit d91aef012e
15 changed files with 557 additions and 13 deletions
+16
View File
@@ -89,6 +89,22 @@ WEB_APP_BASE_URL=https://account.netrunner-vpn.com
# (NodeService::provision_node_via_ssh) и в .env.example netrunner-proxy.
PROXY_INTERNAL_SECRET=CHANGE_ME_PROXY_INTERNAL_SECRET_openssl_rand_hex_32
# =====================================================================
# === РЕГИОНАЛЬНЫЕ ЦЕНЫ: GEOIP + VPN-ДЕТЕКТ (см. modules::geoip) ===
# =====================================================================
# Все три — опциональны, дефолты в main.rs указывают прямо на публичные
# раздачи, ничего заполнять не обязательно для старта:
# GEOIP_DB_URL_TEMPLATE — DB-IP Lite (.mmdb.gz, тот же формат, что
# MaxMind, но без регистрации/лицензионного ключа), "{}" — плейсхолдер
# под "YYYY-MM" (у DB-IP имя файла с датой, единой "latest"-ссылки нет).
# VPN_LIST_URL — X4BNet lists_vpn, открытый список CIDR-диапазонов
# известных VPN/датацентров (эвристика, не 100% точная — см. их README).
# GEOIP_REFRESH_INTERVAL — как часто (сек) перекачивать обе базы,
# по умолчанию раз в сутки (86400).
# GEOIP_DB_URL_TEMPLATE=https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz
# VPN_LIST_URL=https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt
# GEOIP_REFRESH_INTERVAL=86400
# =====================================================================
# === APPLICATION RUNTIME SETTINGS ===
# =====================================================================
Generated
+65
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.7.8"
@@ -649,6 +655,15 @@ version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
[[package]]
name = "crc32fast"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
@@ -1056,6 +1071,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "flate2"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
dependencies = [
"crc32fast",
"miniz_oxide",
]
[[package]]
name = "flume"
version = "0.11.1"
@@ -1856,6 +1881,15 @@ version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "ipnetwork"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e"
dependencies = [
"serde",
]
[[package]]
name = "iri-string"
version = "0.7.12"
@@ -2055,6 +2089,18 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "maxminddb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c"
dependencies = [
"ipnetwork",
"log",
"memchr",
"serde",
]
[[package]]
name = "md-5"
version = "0.10.6"
@@ -2135,6 +2181,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.2.0"
@@ -2173,9 +2229,12 @@ dependencies = [
"chrono",
"clap",
"dotenvy",
"flate2",
"hex",
"hmac 0.13.0",
"ipnet",
"jsonwebtoken",
"maxminddb",
"metrics",
"metrics-exporter-prometheus",
"rand 0.8.6",
@@ -3451,6 +3510,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simdutf8"
version = "0.1.5"
+3
View File
@@ -40,6 +40,9 @@ argon2 = "0.5"
rpassword = "7.5"
rust_decimal = { version = "1.42.0", features = ["serde"] }
reqwest = { version = "0.11", features = ["json"] }
maxminddb = "0.24"
ipnet = "2.10"
flate2 = "1.0"
[profile.release]
opt-level = 3
+11 -1
View File
@@ -28,6 +28,7 @@ use crate::db::repository::AppRepository;
use crate::modules::{
auth::{controller as auth_ctrl, service::AuthService},
billing::{controller as bill_ctrl, service::BillingService},
geoip::service::GeoipService,
nodes::{controller as node_ctrl, service::NodeService},
proxy_internal,
referrals::{controller as ref_ctrl, service::ReferralService},
@@ -45,6 +46,11 @@ pub struct ApiState {
pub user_service: UserService,
pub staff_service: StaffService,
pub support_service: SupportService,
/// Страна-по-IP + грубая VPN/датацентр-эвристика — определяет валюту
/// региональной цены в `GET /plans` (см. modules::geoip). Не используется
/// в боте (Telegram Bot API не отдаёт IP пользователя вообще) — там
/// валюта определяется по `language_code`, см. bot.rs.
pub geoip_service: GeoipService,
pub jwt_secret: String,
pub bot_token: String,
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
@@ -87,6 +93,7 @@ impl Clone for ApiState {
user_service: self.user_service.clone(),
staff_service: self.staff_service.clone(),
support_service: self.support_service.clone(),
geoip_service: self.geoip_service.clone(),
jwt_secret: self.jwt_secret.clone(),
bot_token: self.bot_token.clone(),
bot_username: self.bot_username.clone(),
@@ -135,7 +142,10 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
)
.nest("/users", user_ctrl::router(state.clone()))
.nest("/billing", bill_ctrl::router(state.clone()))
.nest(
"/billing",
bill_ctrl::router(state.clone()).merge(bill_ctrl::public_router()),
)
.nest("/nodes", node_ctrl::public_router(state.clone()))
.nest("/referrals", ref_ctrl::router(state.clone()))
.route("/config", get(get_public_config))
+31 -5
View File
@@ -208,6 +208,12 @@ async fn handle_callbacks(
support: SupportService,
config: BotConfig,
) -> ResponseResult<()> {
// Единственный доступный сигнал региона в боте — Bot API не отдаёт IP
// пользователя вообще (в отличие от сайта, см.
// billing::controller::list_plans_api). Грубее геолокации (язык клиента
// Telegram ≠ страна — диаспора, мультиязычные регионы), но лучше, чем
// показывать всем только USD.
let lang_code = q.from.language_code.clone();
if let (Some(data), Some(msg)) = (q.data, q.message) {
let tg_id = q.from.id.0 as i64;
info!(callback_data = %data, "Пользователь нажал кнопку в боте");
@@ -292,17 +298,37 @@ async fn handle_callbacks(
)
.await,
// Оплата криптой прямо в чате, без WebApp/кошелька — юзер платит
// внутри @CryptoBot, возвращается и жмёт "Я оплатил".
"menu_pay_crypto" => match billing.list_plans_usd().await {
Ok(plans) if !plans.is_empty() => {
// внутри @CryptoBot, возвращается и жмёт "Я оплатил". Показываем
// цену в валюте региона (по language_code, см. выше) для
// наглядности — САМА оплата остаётся в USD/USDT (см.
// pay_plan-ветку ниже): CryptoBotProvider биллит 1:1 к
// USD-цене тарифа, менять валюту списания нельзя, иначе при
// показе, скажем, VND юзер спишет в разы больше/меньше нужного.
"menu_pay_crypto" => match billing
.list_plans_in_currency(
lang_code
.as_deref()
.and_then(crate::modules::geoip::currency::currency_for_telegram_lang)
.unwrap_or("USD"),
)
.await
{
Ok((display_currency, plans)) if !plans.is_empty() => {
let rows = plans
.chunks(1)
.map(|chunk| {
chunk
.iter()
.map(|(name, cents)| {
.map(|(name, amount)| {
InlineKeyboardButton::callback(
format!("{} — ${:.2}", name, *cents as f64 / 100.0),
format!(
"{}{}",
name,
crate::modules::geoip::currency::format_amount(
&display_currency,
*amount
)
),
format!("pay_plan:{}", name),
)
})
+37 -3
View File
@@ -17,8 +17,8 @@ use netrunner_control_plane::api::{create_router, ApiState};
use netrunner_control_plane::bot;
use netrunner_control_plane::db::repository::{AppRepository, PgRepository};
use netrunner_control_plane::modules::{
auth::service::AuthService, billing::service::BillingService, nodes::service::NodeService,
referrals::service::ReferralService, staff::service::StaffService,
auth::service::AuthService, billing::service::BillingService, geoip::service::GeoipService,
nodes::service::NodeService, referrals::service::ReferralService, staff::service::StaffService,
support::service::SupportService, users::service::UserService,
};
use netrunner_control_plane::tasks;
@@ -289,6 +289,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let cancel_token = CancellationToken::new();
// DB-IP Lite (не MaxMind — тот же формат .mmdb, но без регистрации/ключа,
// см. modules::geoip) + X4BNet lists_vpn (открытый список VPN/датацентр
// CIDR-диапазонов). Дефолты указывают прямо на публичные раздачи — можно
// переопределить, если когда-нибудь понадобится своё зеркало.
let geoip_db_url_template = std::env::var("GEOIP_DB_URL_TEMPLATE").unwrap_or_else(|_| {
"https://download.db-ip.com/free/dbip-country-lite-{}.mmdb.gz".to_string()
});
let vpn_list_url = std::env::var("VPN_LIST_URL").unwrap_or_else(|_| {
"https://raw.githubusercontent.com/X4BNet/lists_vpn/main/output/vpn/ipv4.txt".to_string()
});
let geoip_service = GeoipService::new(geoip_db_url_template, vpn_list_url);
let state = ApiState {
repo: repo.clone(),
auth_service: AuthService::new(repo.clone()),
@@ -298,6 +310,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
support_service: SupportService::new(repo.clone()),
geoip_service: geoip_service.clone(),
jwt_secret,
bot_token: bot_token.clone(),
bot_username,
@@ -348,12 +361,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
// Первая загрузка баз GeoIP/VPN-списка — не блокирует старт HTTP-сервера
// (сеть может быть недоступна/медленной на старте контейнера), но
// запускается сразу, не через сутки ожидания первого тика в
// run_background_tasks.
let startup_geoip = geoip_service.clone();
tokio::spawn(async move {
if let Err(e) = startup_geoip.refresh_geoip_db().await {
tracing::warn!(
"GeoIP: первичная загрузка базы страна-по-IP не удалась: {}",
e
);
}
if let Err(e) = startup_geoip.refresh_vpn_list().await {
tracing::warn!(
"GeoIP: первичная загрузка списка VPN-диапазонов не удалась: {}",
e
);
}
});
let tasks_token = cancel_token.clone();
let tasks_billing = state.billing_service.clone();
let tasks_repo = repo.clone();
let tasks_geoip = geoip_service.clone();
let tasks_handle = tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_token).await;
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_geoip, tasks_token).await;
});
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===
+4
View File
@@ -221,6 +221,10 @@ mod tests {
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
geoip_service: crate::modules::geoip::service::GeoipService::new(
"https://example.invalid/{}.mmdb.gz".into(),
"https://example.invalid/vpn.txt".into(),
),
jwt_secret: "test-secret".into(),
bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(),
+79 -3
View File
@@ -1,14 +1,21 @@
use axum::{
extract::{Extension, State},
extract::{ConnectInfo, Extension, State},
http::HeaderMap,
middleware,
routing::post,
routing::{get, post},
Json, Router,
};
use serde::Deserialize;
use serde_json::{json, Value};
use std::net::{IpAddr, SocketAddr};
use uuid::Uuid;
use crate::{api::ApiState, db::models::User, error::AppError, modules::auth::guard::auth_guard};
use crate::{
api::ApiState,
db::models::User,
error::AppError,
modules::{auth::guard::auth_guard, geoip::currency::currency_for_country},
};
#[derive(Deserialize)]
pub struct InitiatePaymentPayload {
@@ -23,6 +30,13 @@ pub struct ConfirmPaymentPayload {
pub external_tx_id: String,
}
/// Без auth_guard — цены должны быть видны анонимному посетителю сайта
/// (см. netrunner-landing/components/sections/Pricing.tsx), не только
/// залогиненным.
pub fn public_router() -> Router<ApiState> {
Router::new().route("/plans", get(list_plans_api))
}
pub fn router(state: ApiState) -> Router<ApiState> {
Router::new()
.route("/pay/initiate", post(initiate_payment_api))
@@ -31,6 +45,68 @@ pub fn router(state: ApiState) -> Router<ApiState> {
.route_layer(middleware::from_fn_with_state(state, auth_guard))
}
/// IP реального посетителя — из `X-Real-IP` (его ставит nginx на проде, см.
/// netrunner-data/nginx/*, netrunner-backend/nginx/*, восстановлен из
/// `CF-Connecting-IP` через `set_real_ip_from`/`real_ip_header`), иначе
/// `X-Forwarded-For` (первый адрес в цепочке), иначе — реальный TCP-пир
/// (для локальной разработки без nginx впереди).
fn extract_client_ip(headers: &HeaderMap, connect_info: &SocketAddr) -> IpAddr {
if let Some(real_ip) = headers
.get("x-real-ip")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse().ok())
{
return real_ip;
}
if let Some(forwarded) = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.and_then(|s| s.trim().parse().ok())
{
return forwarded;
}
connect_info.ip()
}
/// Тарифы в валюте, определённой по региону посетителя. Если IP похож на
/// VPN/датацентр (см. `geoip_service.is_vpn`) — намеренно НЕ используем
/// региональную цену, отдаём базовую USD (иначе покупка "из региона" через
/// любой публичный VPN обесценивала бы всю схему региональных скидок).
async fn list_plans_api(
State(state): State<ApiState>,
headers: HeaderMap,
ConnectInfo(connect_info): ConnectInfo<SocketAddr>,
) -> Result<Json<Value>, AppError> {
let ip = extract_client_ip(&headers, &connect_info);
let is_vpn = state.geoip_service.is_vpn(ip).await;
let country = state.geoip_service.country_for_ip(ip).await;
let currency = if is_vpn {
"USD"
} else {
country
.as_deref()
.and_then(currency_for_country)
.unwrap_or("USD")
};
let (resolved_currency, plans) = state
.billing_service
.list_plans_in_currency(currency)
.await?;
Ok(Json(json!({
"currency": resolved_currency,
"detected_country": country,
"vpn_detected": is_vpn,
"plans": plans.into_iter().map(|(name, amount)| json!({
"plan": name,
"amount": amount,
})).collect::<Vec<_>>(),
})))
}
/// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.)
async fn initiate_payment_api(
Extension(user): Extension<User>,
+24
View File
@@ -110,6 +110,30 @@ impl BillingService {
.map_err(AppError::Database)
}
/// Тарифы в конкретной валюте — для `GET /plans` (см.
/// `billing::controller::list_plans_api`) и мультивалютного бота
/// (`bot.rs`). Если для этой валюты в `plan_prices` нет ни одной активной
/// строки (валюта определилась, но регион не заведён — маловероятно,
/// т.к. валюта берётся из той же карты, что и миграция, но на всякий
/// случай) — тихо фолбэкается на USD, а не отдаёт пустой список.
pub async fn list_plans_in_currency(
&self,
currency: &str,
) -> Result<(String, Vec<(String, i64)>), AppError> {
if currency != "USD" {
let regional = self
.repo
.list_active_plans_by_currency(currency)
.await
.map_err(AppError::Database)?;
if !regional.is_empty() {
return Ok((currency.to_string(), regional));
}
}
let usd = self.list_plans_usd().await?;
Ok(("USD".to_string(), usd))
}
/// Курс TON к валюте из кеша; `None` при промахе/недоступности Redis —
/// вызывающий код должен сходить за свежим значением и заполнить кеш сам.
async fn get_cached_ton_rate(&self, currency_lower: &str) -> Option<rust_decimal::Decimal> {
+100
View File
@@ -0,0 +1,100 @@
//! Статические таблицы "страна/язык → валюта". Не БД — список валют вообще
//! завязан на то, что реально заведено в `plan_prices` (см. `migrations/
//! 0001_init.sql` + `0007_regional_pricing.sql`), т.е. это код, а не
//! конфигурация — добавление новой валюты всё равно требует миграции.
/// ISO 3166-1 alpha-2 страна → код валюты в `plan_prices.currency`.
/// Нет записи — значит для этой страны своей региональной цены нет,
/// вызывающая сторона обязана фолбэкнуться на `"USD"`.
pub fn currency_for_country(country_code: &str) -> Option<&'static str> {
match country_code {
"RU" => Some("RUB"),
"CN" => Some("CNY"),
"TR" => Some("TRY"),
"IR" => Some("IRR"),
"UA" => Some("UAH"),
"BY" => Some("BYN"),
"VN" => Some("VND"),
"UZ" => Some("UZS"),
_ => None,
}
}
/// Telegram `language_code` → валюта — единственный доступный сигнал в
/// боте (Bot API не отдаёт IP пользователя вообще, см. `mod.rs`). Грубее
/// геолокации (язык ≠ страна: диаспора, мультиязычные регионы), но лучше,
/// чем everyone-USD.
pub fn currency_for_telegram_lang(lang_code: &str) -> Option<&'static str> {
// Telegram присылает "ru", "uk", иногда с региональным суффиксом
// ("en-US") — берём только первичный подтег.
let primary = lang_code.split(['-', '_']).next().unwrap_or(lang_code);
match primary {
"ru" => Some("RUB"),
"zh" => Some("CNY"),
"tr" => Some("TRY"),
"fa" => Some("IRR"),
"uk" => Some("UAH"),
"be" => Some("BYN"),
"vi" => Some("VND"),
"uz" => Some("UZS"),
_ => None,
}
}
/// Символ валюты для отображения (бот, потенциально фронт).
pub fn currency_symbol(currency: &str) -> &'static str {
match currency {
"USD" => "$",
"RUB" => "",
"CNY" => "¥",
"TRY" => "",
"IRR" => "",
"UAH" => "",
"BYN" => "Br",
"VND" => "",
"UZS" => "сум",
_ => "$",
}
}
/// IRR/VND/UZS хранятся в `plan_prices.amount` целыми единицами (не центами/
/// копейками) — см. комментарий в migrations/0007_regional_pricing.sql: у
/// этих валют нет ходовой разменной монеты, дробные суммы бессмысленны для
/// пользователя.
fn is_whole_unit_currency(currency: &str) -> bool {
matches!(currency, "IRR" | "VND" | "UZS")
}
/// `amount` — как хранится в `plan_prices` (центы/копейки для большинства
/// валют, целые единицы для IRR/VND/UZS, см. `is_whole_unit_currency`).
/// Возвращает готовую для показа строку с символом валюты, БЕЗ значка
/// тысячных разделителей (это I18n-забота фронта/бота, если понадобится).
pub fn format_amount(currency: &str, amount: i64) -> String {
let symbol = currency_symbol(currency);
if is_whole_unit_currency(currency) {
format!("{} {}", amount, symbol)
} else {
format!("{}{:.2}", symbol, amount as f64 / 100.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn country_lookup_known_and_unknown() {
assert_eq!(currency_for_country("RU"), Some("RUB"));
assert_eq!(currency_for_country("UZ"), Some("UZS"));
assert_eq!(currency_for_country("US"), None);
assert_eq!(currency_for_country(""), None);
}
#[test]
fn telegram_lang_strips_region_suffix() {
assert_eq!(currency_for_telegram_lang("ru"), Some("RUB"));
assert_eq!(currency_for_telegram_lang("ru-RU"), Some("RUB"));
assert_eq!(currency_for_telegram_lang("en-US"), None);
assert_eq!(currency_for_telegram_lang("en"), None);
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Определение страны по IP (self-hosted DB-IP Lite, формат `.mmdb` — тот же,
//! что у MaxMind, но без регистрации/лицензионного ключа, см. README ниже) и
//! грубая эвристика "это VPN/датацентр?" по открытому списку CIDR-диапазонов
//! (X4BNet `lists_vpn`, тоже без ключей/оплаты).
//!
//! Обе базы скачиваются в рантайме (см. `service::GeoipService::refresh_*`),
//! не коммитятся в репозиторий — только пути/URL в конфиге. Периодическое
//! обновление — фоновая задача в `tasks.rs`, не здесь.
//!
//! Region-based pricing работает только там, где есть IP посетителя (сайт,
//! `GET /plans` — см. `modules::billing::controller`) — у Telegram-бота IP
//! юзера не видно вообще (ограничение самого Bot API), поэтому там валюта
//! определяется по `language_code` (см. `currency::currency_for_telegram_lang`).
pub mod currency;
pub mod service;
+143
View File
@@ -0,0 +1,143 @@
//! Держит в памяти два периодически обновляемых источника:
//! - `.mmdb`-база страна-по-IP (DB-IP Lite, тот же формат, что MaxMind
//! GeoLite2, но без регистрации/лицензионного ключа — просто прямая
//! ссылка на скачивание, обновляется у них раз в месяц).
//! - список CIDR-диапазонов известных VPN/датацентров (X4BNet `lists_vpn`,
//! тоже открытый, без ключей) — грубая эвристика, не идеальная (см. их
//! же README), но достаточная для "не давать региональную скидку через
//! очевидный VPN", не для полноценного антифрода.
//!
//! Обновление обеих баз — фоновая периодическая задача (см. `tasks.rs`), не
//! часть этого файла: сервис только хранит текущее состояние и умеет его
//! перезаписать (`refresh_*`), вызывающая сторона решает, когда дёргать.
use flate2::read::GzDecoder;
use ipnet::IpNet;
use serde::Deserialize;
use std::io::Read;
use std::net::IpAddr;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{info, warn};
#[derive(Deserialize)]
struct MmdbCountryLookup<'a> {
#[serde(borrow)]
country: Option<MmdbCountryCode<'a>>,
}
#[derive(Deserialize)]
struct MmdbCountryCode<'a> {
iso_code: Option<&'a str>,
}
#[derive(Clone)]
pub struct GeoipService {
reader: Arc<RwLock<Option<maxminddb::Reader<Vec<u8>>>>>,
vpn_ranges: Arc<RwLock<Vec<IpNet>>>,
/// Шаблон URL с одним `{}` под "YYYY-MM" — DB-IP публикует файл с датой
/// в имени, единой "latest"-ссылки у них нет.
geoip_url_template: String,
vpn_list_url: String,
}
impl GeoipService {
pub fn new(geoip_url_template: String, vpn_list_url: String) -> Self {
Self {
reader: Arc::new(RwLock::new(None)),
vpn_ranges: Arc::new(RwLock::new(Vec::new())),
geoip_url_template,
vpn_list_url,
}
}
/// ISO 3166-1 alpha-2 код страны по IP, `None` — база ещё не загрузилась
/// (первые секунды после старта) или IP не нашёлся (приватный диапазон,
/// пробел в данных DB-IP).
pub async fn country_for_ip(&self, ip: IpAddr) -> Option<String> {
let guard = self.reader.read().await;
let reader = guard.as_ref()?;
let lookup: MmdbCountryLookup = reader.lookup(ip).ok()?;
lookup
.country
.and_then(|c| c.iso_code)
.map(|s| s.to_string())
}
/// Грубая проверка "похоже на VPN/датацентр" — see module doc про точность.
pub async fn is_vpn(&self, ip: IpAddr) -> bool {
let guard = self.vpn_ranges.read().await;
guard.iter().any(|net| net.contains(&ip))
}
/// Скачивает и перезагружает `.mmdb`. Пробует текущий месяц, затем
/// предыдущий (файл за новый месяц у DB-IP иногда появляется с
/// задержкой в первые дни) — до первого успешного скачивания.
pub async fn refresh_geoip_db(&self) -> Result<(), String> {
let now = chrono::Utc::now();
for months_back in 0..=1i64 {
let date = now - chrono::Duration::days(31 * months_back);
let month_tag = date.format("%Y-%m").to_string();
let url = self.geoip_url_template.replace("{}", &month_tag);
match Self::try_download_mmdb(&url).await {
Ok(reader) => {
*self.reader.write().await = Some(reader);
info!(url = %url, "GeoIP: база страна-по-IP обновлена");
return Ok(());
}
Err(e) => {
warn!(url = %url, error = %e, "GeoIP: не удалось скачать за этот месяц, пробую предыдущий");
}
}
}
Err("GeoIP: не удалось скачать .mmdb ни за текущий, ни за прошлый месяц".into())
}
async fn try_download_mmdb(url: &str) -> Result<maxminddb::Reader<Vec<u8>>, String> {
let resp = reqwest::get(url)
.await
.map_err(|e| format!("HTTP-запрос: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let compressed = resp
.bytes()
.await
.map_err(|e| format!("тело ответа: {e}"))?;
let mut decoder = GzDecoder::new(&compressed[..]);
let mut decompressed = Vec::new();
decoder
.read_to_end(&mut decompressed)
.map_err(|e| format!("gzip-распаковка: {e}"))?;
maxminddb::Reader::from_source(decompressed).map_err(|e| format!("парсинг .mmdb: {e}"))
}
/// Скачивает и перезагружает список CIDR-диапазонов VPN/датацентров.
pub async fn refresh_vpn_list(&self) -> Result<(), String> {
let resp = reqwest::get(&self.vpn_list_url)
.await
.map_err(|e| format!("HTTP-запрос: {e}"))?;
if !resp.status().is_success() {
return Err(format!("HTTP {}", resp.status()));
}
let text = resp.text().await.map_err(|e| format!("тело ответа: {e}"))?;
let ranges: Vec<IpNet> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.filter_map(|l| l.parse().ok())
.collect();
if ranges.is_empty() {
return Err("список VPN-диапазонов пуст после парсинга — подозрительно, не перезаписываю текущий".into());
}
let count = ranges.len();
*self.vpn_ranges.write().await = ranges;
info!(count, "GeoIP: список VPN/датацентр-диапазонов обновлён");
Ok(())
}
}
+1
View File
@@ -3,6 +3,7 @@
pub mod auth;
pub mod billing;
pub mod geoip;
pub mod nodes;
pub mod proxy_internal;
pub mod referrals;
+4
View File
@@ -144,6 +144,10 @@ mod tests {
user_service: UserService::new(repo.clone()),
staff_service: StaffService::new(repo.clone()),
support_service: crate::modules::support::service::SupportService::new(repo.clone()),
geoip_service: crate::modules::geoip::service::GeoipService::new(
"https://example.invalid/{}.mmdb.gz".into(),
"https://example.invalid/vpn.txt".into(),
),
jwt_secret: SECRET.into(),
bot_token: "test-bot-token".into(),
bot_username: "test_bot".into(),
+24 -1
View File
@@ -3,10 +3,13 @@
//! подписки active -> grace -> expired и сверяет блокчейн на предмет платежей, чьё
//! подтверждение потерялось (см. `BillingService::reconcile_pending_payments`);
//! - раз в `NODE_HEALTH_CHECK_INTERVAL` секунд (по умолчанию 60) пингует все VPN-ноды
//! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`).
//! по TCP и переоценивает их статус `online`/`offline` (см. `run_node_health_check`);
//! - раз в `GEOIP_REFRESH_INTERVAL` секунд (по умолчанию раз в сутки) перекачивает
//! базу страна-по-IP и список VPN/датацентр-диапазонов (см. `modules::geoip`).
use crate::db::repository::AppRepository;
use crate::modules::billing::service::BillingService;
use crate::modules::geoip::service::GeoipService;
use std::{env, net::SocketAddr, sync::Arc, time::Duration};
use tokio::{net::TcpStream, time};
use tokio_util::sync::CancellationToken;
@@ -17,6 +20,7 @@ const HEALTH_CHECK_TIMEOUT: Duration = Duration::from_secs(3);
pub async fn run_background_tasks(
billing: BillingService,
repo: Arc<dyn AppRepository>,
geoip: GeoipService,
cancel_token: CancellationToken,
) {
let interval_secs = env::var("SUBSCRIPTION_CHECK_INTERVAL")
@@ -27,6 +31,10 @@ pub async fn run_background_tasks(
.unwrap_or_else(|_| "60".to_string())
.parse::<u64>()
.unwrap_or(60);
let geoip_refresh_interval_secs = env::var("GEOIP_REFRESH_INTERVAL")
.unwrap_or_else(|_| "86400".to_string())
.parse::<u64>()
.unwrap_or(86400);
info!(
"🔄 Фоновый биллинг-воркер запущен (интервал: {} сек)",
@@ -36,9 +44,16 @@ pub async fn run_background_tasks(
"🩺 Health-check воркер VPN-нод запущен (интервал: {} сек)",
health_check_interval_secs
);
info!(
"🌍 GeoIP-воркер запущен (интервал: {} сек)",
geoip_refresh_interval_secs
);
let mut billing_interval = time::interval(Duration::from_secs(interval_secs));
let mut health_interval = time::interval(Duration::from_secs(health_check_interval_secs));
// Первый тик срабатывает сразу (не ждёт полный интервал) — иначе первые
// сутки после старта сервиса /plans работал бы вообще без базы GeoIP.
let mut geoip_interval = time::interval(Duration::from_secs(geoip_refresh_interval_secs));
loop {
tokio::select! {
@@ -63,6 +78,14 @@ pub async fn run_background_tasks(
_ = health_interval.tick() => {
run_node_health_check(&repo).await;
}
_ = geoip_interval.tick() => {
if let Err(e) = geoip.refresh_geoip_db().await {
error!("GeoIP: ошибка обновления базы страна-по-IP: {}", e);
}
if let Err(e) = geoip.refresh_vpn_list().await {
error!("GeoIP: ошибка обновления списка VPN-диапазонов: {}", e);
}
}
_ = cancel_token.cancelled() => { break; }
}
}