Unify auth into refresh-token cookie sessions; server-authoritative nodes and Cyberhack

Auth was effectively broken for every login path: the Telegram handler
built a Set-Cookie header but never attached it to the response, and
Ghost Protocol (seed) login never set a cookie at all — so billing, the
game, and admin actions never actually worked once the frontend was fixed
to stop trying to read an HttpOnly cookie from JS. Replaced the bare
10-minute JWT with AuthService::issue_session: a 15-minute access cookie
plus a rotating 30-day refresh cookie (opaque token, only its SHA-256
hash stored in the new refresh_sessions table); reusing an already-
rotated refresh token now revokes every session for that user. CSRF/CORS
origin allowlists moved from a single hardcoded domain to env-configured
lists so the same session works across the landing and account subdomains.
The bot's WebApp deep links (Личный Кабинет/Тарифы/Синдикат) now bridge
through a short-lived bootstrap token exchanged via POST /auth/exchange
instead of a bare access token in the URL.

Nodes: /nodes/routing now serializes the tunnel_* fields, public_key,
sni_domain and a one-time session token gated on an active subscription
instead of a stub ip/port/protocol list; node provisioning returns 202
immediately and finishes in the background instead of blocking the
request for the whole SSH run; a new background task TCP-pings nodes
every 60s and flips online/offline itself; admin can now force-restart a
node over SSH.

Billing: TON exchange rate is cached in Redis (60s) instead of hitting
TonAPI on every invoice, and the request/response now actually uses the
requested currency and TonAPI's real (uppercase) key casing — previously
only USD/RUB were ever requested and the lookup used the wrong case, so
non-USD/RUB plans could never price correctly.

Cyberhack: the server now generates and stores the board itself and
replays the client's raw click path to compute the score, instead of
clamping a client-reported number — closes the "final score is whatever
the browser sends" hole flagged in the security audit.

Frontend: api.ts no longer tries to read the HttpOnly session cookie from
JS (that never worked) and instead relies on same-origin credentials plus
a silent refresh-and-retry on 401; AdminDashboard's restart/delete actions
are wired up; Auth.tsx drops the artificial delays and requires an
explicit seed download/confirmation before continuing, since a lost Ghost
seed is unrecoverable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 15:29:51 +07:00
parent 588adbb92b
commit 52531dd41a
41 changed files with 2219 additions and 508 deletions
+51 -6
View File
@@ -122,20 +122,21 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// после старта контейнера, даже когда db уже healthy. Делаем несколько попыток.
let pool = {
let url = db_url.trim();
let mut last_err = None;
let mut attempt = 0u8;
loop {
attempt += 1;
match PgPoolOptions::new().max_connections(20).connect(url).await {
Ok(p) => break p,
Err(e) if attempt < 5 => {
tracing::warn!("БД недоступна (попытка {}/5): {}. Повтор через 3с…", attempt, e);
tracing::warn!(
"БД недоступна (попытка {}/5): {}. Повтор через 3с…",
attempt,
e
);
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
last_err = Some(e);
}
Err(e) => return Err(e.into()),
}
let _ = last_err;
}
};
@@ -176,19 +177,62 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
info!("🚀 Запуск Netrunner Control Plane v2.0 (Modular Architecture)");
// Домен для Set-Cookie: пусто локально (host-only cookie), в проде
// `.netrunner-vpn.com` — расшаривает сессию между лендингом и ЛК на сабдомене.
let cookie_domain = std::env::var("COOKIE_DOMAIN").unwrap_or_default();
let csrf_allowed_origins: Vec<String> = std::env::var("CSRF_ALLOWED_ORIGINS")
.unwrap_or_else(|_| {
"https://netrunner-vpn.com,https://account.netrunner-vpn.com".to_string()
})
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Redis — только кеш (курс TON), не источник истины: недоступность не должна
// блокировать старт приложения, просто отключает кеш и каждый инвойс идёт
// напрямую в TonAPI (как было раньше).
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://redis:6379".into());
let redis_conn = match redis::Client::open(redis_url.as_str()) {
Ok(client) => match redis::aio::ConnectionManager::new(client).await {
Ok(conn) => {
info!("✅ Redis подключён ({})", redis_url);
Some(conn)
}
Err(e) => {
tracing::warn!(
"⚠️ Redis недоступен ({}): {}. Кеш курса TON отключён.",
redis_url,
e
);
None
}
},
Err(e) => {
tracing::warn!(
"⚠️ Некорректный REDIS_URL ({}): {}. Кеш отключён.",
redis_url,
e
);
None
}
};
let repo: Arc<dyn AppRepository> = Arc::new(PgRepository::new(pool.clone()));
let cancel_token = CancellationToken::new();
let state = ApiState {
repo: repo.clone(),
auth_service: AuthService::new(repo.clone()),
billing_service: BillingService::new(repo.clone()),
billing_service: BillingService::new(repo.clone(), redis_conn),
node_service: NodeService::new(repo.clone()),
referral_service: ReferralService::new(repo.clone()),
user_service: UserService::new(repo.clone()),
jwt_secret,
bot_token: bot_token.clone(),
admin_tg_id,
cookie_domain,
csrf_allowed_origins,
};
let app = create_router(state.clone(), &frontend_dir);
@@ -230,9 +274,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let tasks_token = cancel_token.clone();
let tasks_billing = state.billing_service.clone();
let tasks_repo = repo.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_token).await;
tasks::run_background_tasks(tasks_billing, tasks_repo, tasks_token).await;
});
// === КООРДИНАТОР АВАРИЙНОГО И СТАНДАРТНОГО ШУТДАУНА ===