security: fix CSRF/ownership/timing vulns; add billing reconciliation
- auth/guard: добавить CSRF-проверку Origin/Referer для cookie-мутаций, исправить инвертированную логику admin_guard (пускал всех, блокировал админов), перевести admin_guard на from_fn_with_state - auth/service: timing-safe сравнение HMAC через verify_slice вместо != по hex; криптостойкий Seed через Alphanumeric (был цифровой 0-9) - billing: confirm_payment проверяет owner (caller_id == invoice.user_id); активация триала только для Telegram-аккаунтов (Sybil-защита); реализована reconcile_pending_payments — сверка blockchain->pending-инвойсы - billing/providers: get_recent_transactions перенесён в трейт PaymentProvider - nodes/service: unwrap() -> map_err по всему SSH-пути; убран TcpStream import - docker: multi-stage build с cargo-chef, non-root user, healthcheck; prod-compose добавляет one-shot migrate-сервис, убирает проброс порта БД - deploy.yml: параметр image_tag, set -e, pull всего стека вместо только app - users/service: обработка NO_TICKETS / NO_USER из БД вместо catch-all Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+56
-12
@@ -1,10 +1,16 @@
|
||||
use axum::{
|
||||
extract::{MatchedPath, Request},
|
||||
extract::{MatchedPath, Request, State},
|
||||
http::{
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
Method, StatusCode,
|
||||
},
|
||||
middleware::{self, Next},
|
||||
response::Response,
|
||||
Router,
|
||||
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::{
|
||||
@@ -31,12 +37,13 @@ pub struct ApiState {
|
||||
pub user_service: UserService,
|
||||
pub jwt_secret: String,
|
||||
pub bot_token: String,
|
||||
pub admin_tg_id: i64,
|
||||
}
|
||||
|
||||
impl Clone for ApiState {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
repo: self.repo.clone(), // Копирует атомарный указатель
|
||||
repo: self.repo.clone(),
|
||||
auth_service: self.auth_service.clone(),
|
||||
billing_service: self.billing_service.clone(),
|
||||
node_service: self.node_service.clone(),
|
||||
@@ -44,6 +51,7 @@ impl Clone for ApiState {
|
||||
user_service: self.user_service.clone(),
|
||||
jwt_secret: self.jwt_secret.clone(),
|
||||
bot_token: self.bot_token.clone(),
|
||||
admin_tg_id: self.admin_tg_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,13 +72,12 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
||||
|
||||
let seed_limit_conf = Arc::new(
|
||||
GovernorConfigBuilder::default()
|
||||
.per_millisecond(5000) // 1 запрос в 5 секунд
|
||||
.burst_size(3) // Максимум 3 запроса подряд
|
||||
.per_millisecond(5000)
|
||||
.burst_size(3)
|
||||
.finish()
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
// Восстанавливаем Prometheus метрики
|
||||
let recorder_handle = PrometheusBuilder::new()
|
||||
.set_buckets_for_metric(
|
||||
Matcher::Full("http_request_duration_seconds".to_string()),
|
||||
@@ -80,7 +87,6 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
||||
.install_recorder()
|
||||
.expect("Failed to install Prometheus");
|
||||
|
||||
// Доменные API роуты (V1)
|
||||
let api_routes_v1 = Router::new()
|
||||
.nest(
|
||||
"/auth",
|
||||
@@ -92,28 +98,66 @@ pub fn create_router(state: ApiState, frontend_dir: &str) -> Router {
|
||||
.nest("/referrals", ref_ctrl::router(state.clone()))
|
||||
.layer(GovernorLayer::new(gov_conf));
|
||||
|
||||
// Секретный админский контур (V1)
|
||||
let admin_routes_v1 = Router::new().nest("/nodes", node_ctrl::admin_router(state.clone()));
|
||||
|
||||
// Строгий 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,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)
|
||||
// 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)) // Встроенный трекинг метрик
|
||||
.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(CorsLayer::permissive())
|
||||
.layer(cors)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// Глобальный трекинг метрик Prometheus
|
||||
/// 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
|
||||
|
||||
Reference in New Issue
Block a user