375b333978
Ролевая система вместо привязки к Telegram ---------------------------------------- Раньше единственный способ получить админ-доступ — admin_guard, пускавший по ADMIN_TG_ID или role=="admin" (без сеттера для роли). Заменяет на Owner/Moderator/Partner: - migrations/0005_roles_and_partners.sql — username/password_hash на users (NULL у обычных Ghost/Telegram-юзеров, тот же паттерн, что и у seed_hash/ tg_id), can_manage_nodes, commission_percent, partner_code, referred_by_partner_id; новые таблицы partner_commissions и withdrawal_requests. - auth/guard.rs — admin_guard заменён на owner_guard/staff_guard/ node_manage_guard/partner_guard; ADMIN_TG_ID убран из ApiState и main.rs. - auth/service.rs — hash_password/login_staff со случайной Argon2-солью на каждого юзера (не общий фиксированный ARGON_SALT, который годится для высокоэнтропийных seed-логинов, но не для человеческих паролей). - POST /api/v1/auth/admin/login — вход модератора/партнёра/овнера паролем, переиспользует существующий issue_session(). - --create-owner CLI-флаг (main.rs) со скрытым вводом пароля (rpassword) — вместо ручного SQL для создания первого владельца с VPS-консоли. - src/modules/staff/ — эндпоинты создания модераторов/партнёров, переключения can_manage_nodes, списка заявок на вывод и их approve/reject/paid. - Комиссия партнёра начисляется в BillingService::confirm_payment отдельным шагом после существующей рефералки, пересчитывается через get_plan_price(invoice.plan_name, invoice.currency) — НЕ через invoice.amount напрямую, потому что у провайдеров разная внутренняя единица (TON — nanoTON+2% буфер, CryptoBot — фиатные центы напрямую). - bot.rs — /start p_XXXXXX привязывает покупателя к партнёру по коду (first-wins, до существующих веток numeric-tg_id-рефералки и кода входа). - frontend/ — AdminLogin/AdminLayout/StaffPage/WithdrawalsPage/EarningsPage поверх существующего Vite-админ-фронта (без отдельного проекта). Оплата через @CryptoBot ------------------------ - modules/billing/providers/cryptobot.rs — новый PaymentProvider (Crypto Pay API), плюс попутно исправлены два реальных бага в существующем коде: confirm_payment был жёстко завязан на TON-провайдера при любом инвойсе, и InvoiceRecord не хранил currency, из-за чего TON-курс молча применялся ко всем провайдерам вместо родной валюты инвойса. - bot.rs — экран выбора способа оплаты для CryptoBot-инвойсов. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
246 lines
11 KiB
Rust
246 lines
11 KiB
Rust
//! Сборка axum-роутера: монтирует контроллеры всех модулей под `/api/v1` и
|
||
//! `/api/v1/admin`, настраивает rate-limiting (tower_governor), CORS, Prometheus-метрики
|
||
//! и health-пробы. Раздача собранного фронтенда (SPA fallback на index.html) висит на
|
||
//! этом же роутере как `fallback_service`, поэтому бэкенд и фронт всегда деплоятся вместе.
|
||
|
||
use axum::{
|
||
extract::{MatchedPath, Request, State},
|
||
http::{
|
||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||
Method, StatusCode,
|
||
},
|
||
middleware::{self, Next},
|
||
response::{IntoResponse, Response},
|
||
routing::get,
|
||
Json, Router,
|
||
};
|
||
use metrics_exporter_prometheus::{Matcher, PrometheusBuilder};
|
||
use serde_json::json;
|
||
use std::{sync::Arc, time::Instant};
|
||
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
||
use tower_http::{
|
||
cors::CorsLayer,
|
||
services::{ServeDir, ServeFile},
|
||
trace::TraceLayer,
|
||
};
|
||
|
||
use crate::db::repository::AppRepository;
|
||
use crate::modules::{
|
||
auth::{controller as auth_ctrl, service::AuthService},
|
||
billing::{controller as bill_ctrl, service::BillingService},
|
||
nodes::{controller as node_ctrl, service::NodeService},
|
||
proxy_internal,
|
||
referrals::{controller as ref_ctrl, service::ReferralService},
|
||
staff::{controller as staff_ctrl, service::StaffService},
|
||
users::{controller as user_ctrl, service::UserService},
|
||
};
|
||
|
||
pub struct ApiState {
|
||
pub repo: Arc<dyn AppRepository>,
|
||
pub auth_service: AuthService,
|
||
pub billing_service: BillingService,
|
||
pub node_service: NodeService,
|
||
pub referral_service: ReferralService,
|
||
pub user_service: UserService,
|
||
pub staff_service: StaffService,
|
||
pub jwt_secret: String,
|
||
pub bot_token: String,
|
||
/// Юзернейм бота (без `@`) — отдаётся фронту через `GET /api/v1/config`.
|
||
/// Не публичный build-time env (`VITE_BOT_USERNAME`): фронт — статический
|
||
/// SPA-бандл, собираемый один раз в CI и одинаковый что для прод-, что для
|
||
/// dev-деплоя, значение зашилось бы намертво в конкретную сборку и не
|
||
/// подхватывало бы разные боты на разных стендах (ровно так и оказалось на
|
||
/// практике — dev-стенд показывал прод-бота, у которого домен в BotFather
|
||
/// не совпадает с dev-доменом → "Bot domain invalid"). Читается на сервере
|
||
/// заново на каждый запрос, поэтому корректно различается между стендами.
|
||
pub bot_username: String,
|
||
/// `Domain` для сессионных cookie. Пусто (по умолчанию, локальная разработка) →
|
||
/// cookie host-only. В проде — `.netrunner-vpn.com`, чтобы одна сессия работала
|
||
/// и на лендинге, и на ЛК (общий родительский домен, разные сабдомены).
|
||
pub cookie_domain: String,
|
||
/// `Secure`-атрибут сессионных cookie. По умолчанию `true` — обязателен для
|
||
/// прода (HTTPS). На dev-стендах без TLS (голый HTTP на IP/домен, не
|
||
/// `localhost`) браузер и любой нормальный HTTP-клиент **молча отбрасывает**
|
||
/// `Secure`-cookie, полученную не по HTTPS, — сессия не переживает вообще ни
|
||
/// одного запроса. Для таких стендов явно выставляется `false` через
|
||
/// `COOKIE_SECURE=false`.
|
||
pub cookie_secure: bool,
|
||
/// Origin'ы, с которых разрешён cookie-based доступ к мутирующим ручкам
|
||
/// (CSRF-проверка в `auth::guard`). Отдельно от `CORS_ALLOWED_ORIGINS`, т.к. туда
|
||
/// же попадают Bearer-клиенты (Tauri), для которых CSRF неактуален.
|
||
pub csrf_allowed_origins: Vec<String>,
|
||
/// Общий секрет для `netrunner-proxy` (`X-Internal-Secret`) — не пользовательская
|
||
/// авторизация, см. `modules::proxy_internal::internal_secret_guard`.
|
||
pub proxy_internal_secret: String,
|
||
}
|
||
|
||
impl Clone for ApiState {
|
||
fn clone(&self) -> Self {
|
||
Self {
|
||
repo: self.repo.clone(),
|
||
auth_service: self.auth_service.clone(),
|
||
billing_service: self.billing_service.clone(),
|
||
node_service: self.node_service.clone(),
|
||
referral_service: self.referral_service.clone(),
|
||
user_service: self.user_service.clone(),
|
||
staff_service: self.staff_service.clone(),
|
||
jwt_secret: self.jwt_secret.clone(),
|
||
bot_token: self.bot_token.clone(),
|
||
bot_username: self.bot_username.clone(),
|
||
cookie_domain: self.cookie_domain.clone(),
|
||
cookie_secure: self.cookie_secure,
|
||
csrf_allowed_origins: self.csrf_allowed_origins.clone(),
|
||
proxy_internal_secret: self.proxy_internal_secret.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
||
tracing::info!("Initializing Nexus Grid Modular Architecture...");
|
||
|
||
let static_path = std::path::PathBuf::from(frontend_dir);
|
||
let index_path = static_path.join("index.html");
|
||
|
||
let gov_conf = Arc::new(
|
||
GovernorConfigBuilder::default()
|
||
.per_millisecond(100)
|
||
.burst_size(10)
|
||
.finish()
|
||
.unwrap(),
|
||
);
|
||
|
||
let seed_limit_conf = Arc::new(
|
||
GovernorConfigBuilder::default()
|
||
.per_millisecond(5000)
|
||
.burst_size(3)
|
||
.finish()
|
||
.unwrap(),
|
||
);
|
||
|
||
let recorder_handle = PrometheusBuilder::new()
|
||
.set_buckets_for_metric(
|
||
Matcher::Full("http_request_duration_seconds".to_string()),
|
||
&[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
|
||
)
|
||
.unwrap()
|
||
.install_recorder()
|
||
.expect("Failed to install Prometheus");
|
||
|
||
let api_routes_v1 = Router::new()
|
||
.nest(
|
||
"/auth",
|
||
auth_ctrl::router().layer(GovernorLayer::new(seed_limit_conf)),
|
||
)
|
||
.nest("/users", user_ctrl::router(state.clone()))
|
||
.nest("/billing", bill_ctrl::router(state.clone()))
|
||
.nest("/nodes", node_ctrl::public_router(state.clone()))
|
||
.nest("/referrals", ref_ctrl::router(state.clone()))
|
||
.route("/config", get(get_public_config))
|
||
.layer(GovernorLayer::new(gov_conf));
|
||
|
||
let admin_routes_v1 = Router::new()
|
||
.nest("/nodes", node_ctrl::admin_router(state.clone()))
|
||
.nest("/users", user_ctrl::admin_router(state.clone()))
|
||
.nest("/staff", staff_ctrl::router(state.clone()))
|
||
.nest("/staff", staff_ctrl::owner_router(state.clone()))
|
||
.nest("/staff", staff_ctrl::staff_router(state.clone()))
|
||
.nest("/partners", staff_ctrl::partner_router(state.clone()));
|
||
|
||
// Внутренний контракт для прокси-нод — отдельный секрет, не auth_guard
|
||
// (прокси не пользовательская сессия) и не участвует в CORS ниже (никогда
|
||
// не вызывается из браузера).
|
||
let internal_routes = Router::new().nest(
|
||
"/internal",
|
||
proxy_internal::router().layer(middleware::from_fn_with_state(
|
||
state.clone(),
|
||
proxy_internal::internal_secret_guard,
|
||
)),
|
||
);
|
||
|
||
// Строгий CORS. Список разрешённых origin берём из env (через запятую),
|
||
// чтобы помимо веб-фронта пускать и десктоп/мобайл-приложение (Tauri webview).
|
||
let allowed_origins: Vec<axum::http::HeaderValue> = std::env::var("CORS_ALLOWED_ORIGINS")
|
||
.unwrap_or_else(|_| {
|
||
"https://netrunner-vpn.com,https://account.netrunner-vpn.com,\
|
||
tauri://localhost,http://tauri.localhost,\
|
||
http://localhost:1420,http://192.168.110.169:1420"
|
||
.to_string()
|
||
})
|
||
.split(',')
|
||
.filter_map(|s| s.trim().parse::<axum::http::HeaderValue>().ok())
|
||
.collect();
|
||
|
||
let cors = CorsLayer::new()
|
||
.allow_origin(allowed_origins)
|
||
.allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS])
|
||
.allow_credentials(true)
|
||
.allow_headers([AUTHORIZATION, ACCEPT, CONTENT_TYPE]);
|
||
|
||
Router::new()
|
||
.nest("/api/v1", api_routes_v1)
|
||
.nest("/api/v1/admin", admin_routes_v1)
|
||
.nest("/api/v1", internal_routes)
|
||
// Liveness/readiness пробы для Docker/оркестратора и балансировщика.
|
||
.route("/health", get(health_live))
|
||
.route("/health/ready", get(health_ready))
|
||
.route(
|
||
"/metrics",
|
||
axum::routing::get(move || std::future::ready(recorder_handle.render())),
|
||
)
|
||
.route_layer(middleware::from_fn(track_metrics))
|
||
.fallback_service(
|
||
ServeDir::new(&static_path)
|
||
.append_index_html_on_directories(true)
|
||
.fallback(ServeFile::new(&index_path)),
|
||
)
|
||
.layer(cors)
|
||
.layer(TraceLayer::new_for_http())
|
||
.with_state(state)
|
||
}
|
||
|
||
/// Публичная (без auth_guard) рантайм-конфигурация для статического SPA —
|
||
/// см. `ApiState::bot_username` про то, почему это не build-time env фронта.
|
||
async fn get_public_config(State(state): State<ApiState>) -> impl IntoResponse {
|
||
Json(json!({ "bot_username": state.bot_username }))
|
||
}
|
||
|
||
/// Liveness: процесс жив и отвечает. Не трогает зависимости.
|
||
async fn health_live() -> impl IntoResponse {
|
||
(StatusCode::OK, Json(json!({ "status": "ok" })))
|
||
}
|
||
|
||
/// Readiness: готов обслуживать трафик (есть живое соединение с БД).
|
||
async fn health_ready(State(state): State<ApiState>) -> impl IntoResponse {
|
||
match state.repo.ping().await {
|
||
Ok(_) => (StatusCode::OK, Json(json!({ "status": "ready" }))),
|
||
Err(e) => {
|
||
tracing::warn!("Readiness check failed: {:?}", e);
|
||
(
|
||
StatusCode::SERVICE_UNAVAILABLE,
|
||
Json(json!({ "status": "unavailable" })),
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn track_metrics(req: Request, next: Next) -> Response {
|
||
let start = Instant::now();
|
||
let path = req
|
||
.extensions()
|
||
.get::<MatchedPath>()
|
||
.map(|m| m.as_str().to_owned())
|
||
.unwrap_or_else(|| req.uri().path().to_owned());
|
||
let method = req.method().to_string();
|
||
|
||
let response = next.run(req).await;
|
||
|
||
let latency = start.elapsed().as_secs_f64();
|
||
let status = response.status().as_u16().to_string();
|
||
|
||
let labels = [("method", method), ("path", path), ("status", status)];
|
||
metrics::counter!("http_requests_total", &labels).increment(1);
|
||
metrics::histogram!("http_request_duration_seconds", &labels).record(latency);
|
||
|
||
response
|
||
}
|