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:
Kirill
2026-06-29 17:58:24 +07:00
parent 26d6f2db8a
commit f06cc48763
19 changed files with 605 additions and 177 deletions
+56 -12
View File
@@ -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