diff --git a/.env.example b/.env.example index e66160a..3c6d853 100644 --- a/.env.example +++ b/.env.example @@ -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 === # ===================================================================== diff --git a/Cargo.lock b/Cargo.lock index 76fe995..df8158d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5a5825c..fb3e412 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/src/api.rs b/src/api.rs index a6c23d8..87c2e8d 100644 --- a/src/api.rs +++ b/src/api.rs @@ -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)) diff --git a/src/bot.rs b/src/bot.rs index 622f8d4..84c0955 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -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), ) }) diff --git a/src/main.rs b/src/main.rs index 724a061..b1de709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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> { let repo: Arc = 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> { 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> { } }); + // Первая загрузка баз 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; }); // === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА === diff --git a/src/modules/auth/guard.rs b/src/modules/auth/guard.rs index 876713d..ccaaf7a 100644 --- a/src/modules/auth/guard.rs +++ b/src/modules/auth/guard.rs @@ -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(), diff --git a/src/modules/billing/controller.rs b/src/modules/billing/controller.rs index 41ce416..6c6c268 100644 --- a/src/modules/billing/controller.rs +++ b/src/modules/billing/controller.rs @@ -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 { + Router::new().route("/plans", get(list_plans_api)) +} + pub fn router(state: ApiState) -> Router { Router::new() .route("/pay/initiate", post(initiate_payment_api)) @@ -31,6 +45,68 @@ pub fn router(state: ApiState) -> Router { .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, + headers: HeaderMap, + ConnectInfo(connect_info): ConnectInfo, +) -> Result, 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::>(), + }))) +} + /// Шаг 1: Запрашиваем у провайдера данные для оплаты (адрес, ссылку и т.д.) async fn initiate_payment_api( Extension(user): Extension, diff --git a/src/modules/billing/service.rs b/src/modules/billing/service.rs index 7ce120d..4782b53 100644 --- a/src/modules/billing/service.rs +++ b/src/modules/billing/service.rs @@ -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 { diff --git a/src/modules/geoip/currency.rs b/src/modules/geoip/currency.rs new file mode 100644 index 0000000..db84ef2 --- /dev/null +++ b/src/modules/geoip/currency.rs @@ -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); + } +} diff --git a/src/modules/geoip/mod.rs b/src/modules/geoip/mod.rs new file mode 100644 index 0000000..9200f72 --- /dev/null +++ b/src/modules/geoip/mod.rs @@ -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; diff --git a/src/modules/geoip/service.rs b/src/modules/geoip/service.rs new file mode 100644 index 0000000..a16c928 --- /dev/null +++ b/src/modules/geoip/service.rs @@ -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>, +} + +#[derive(Deserialize)] +struct MmdbCountryCode<'a> { + iso_code: Option<&'a str>, +} + +#[derive(Clone)] +pub struct GeoipService { + reader: Arc>>>>, + vpn_ranges: Arc>>, + /// Шаблон 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 { + 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>, 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 = 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(()) + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 74a7afa..7aecf84 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod billing; +pub mod geoip; pub mod nodes; pub mod proxy_internal; pub mod referrals; diff --git a/src/modules/proxy_internal/mod.rs b/src/modules/proxy_internal/mod.rs index 119004d..959a1d8 100644 --- a/src/modules/proxy_internal/mod.rs +++ b/src/modules/proxy_internal/mod.rs @@ -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(), diff --git a/src/tasks.rs b/src/tasks.rs index ae9542c..a42f38a 100644 --- a/src/tasks.rs +++ b/src/tasks.rs @@ -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, + 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::() .unwrap_or(60); + let geoip_refresh_interval_secs = env::var("GEOIP_REFRESH_INTERVAL") + .unwrap_or_else(|_| "86400".to_string()) + .parse::() + .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; } } }