feat: Complete Phase 1 backend services and WebSocket/NATS bridge

Implements all remaining backend infrastructure for Corrosion platform.

Backend Services (5 new):
- license.rs: License validation, activation, check-in with NATS token generation
- map_manager.rs: Map upload/rotation with SHA-256 checksums, circular advancement
- health_checker.rs: Post-wipe verification with retry loop and backoff
- backup_manager.rs: Tar.gz backups with retention policy (last 10), recursive upload
- scheduler.rs: Tokio-cron integration for scheduled wipes with NATS events

WipeEngine Orchestration (wipe_engine.rs):
- execute_wipe(): Master orchestrator managing full lifecycle
- execute_pre_wipe(): Countdown warnings, backups, player kicks
- execute_wipe_actions(): Map/plugin deletion, seed rotation, Steam updates
- execute_post_wipe_verification(): Health checks with restart attempts
- execute_rollback(): Failure recovery with backup restore
- JSONB execution logs, NATS status events, service composition pattern

WebSocket/NATS Bridge (ws.rs):
- JWT authentication via query parameter
- License-scoped NATS subscriptions (corrosion.{license_id}.*)
- Bi-directional: NATS→WebSocket event forwarding, WebSocket→NATS publishing
- Axum 0.8 with ws feature, auto Ping/Pong handling

Panel Adapter Fixes:
- AMP/Pterodactyl/Companion adapters fully wired
- RCON command execution, file operations, Steam update triggers

Fixes:
- Added ws feature to Axum dependency
- Fixed Message::Text() type conversions (String→Utf8Bytes via .into())
- Fixed BackupInfo FromRow derive
- Fixed recursive async with Box::pin pattern
- Fixed async JobScheduler::new() constructor
- Removed manual WebSocket Ping/Pong handler

Compilation: 0 errors, 327 warnings (unused vars/functions)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-15 12:07:01 -05:00
parent a62715409f
commit 590765fbbc
20 changed files with 8677 additions and 443 deletions

View File

@@ -13,3 +13,4 @@ pub mod license;
pub mod store;
pub mod early_access;
pub mod admin;
pub mod ws;

185
backend/src/api/ws.rs Normal file
View File

@@ -0,0 +1,185 @@
use std::sync::Arc;
use axum::{
extract::{
ws::{Message, WebSocket},
Query, State, WebSocketUpgrade,
},
response::Response,
routing::get,
Router,
};
use futures::{sink::SinkExt, stream::StreamExt};
use serde::Deserialize;
use crate::models::error::ApiError;
use crate::services::auth as auth_service;
use crate::AppState;
pub fn router() -> Router<Arc<AppState>> {
Router::new().route("/", get(ws_handler))
}
#[derive(Deserialize)]
struct WsQuery {
token: String,
}
/// WebSocket upgrade handler
///
/// Authenticates via JWT token in query param, then upgrades to WebSocket.
/// Subscribes to NATS events for the user's license and forwards to client.
async fn ws_handler(
ws: WebSocketUpgrade,
Query(query): Query<WsQuery>,
State(state): State<Arc<AppState>>,
) -> Result<Response, ApiError> {
// Validate JWT token
let claims = auth_service::validate_token(&state.config, &query.token)
.map_err(|_| ApiError::Unauthorized)?;
let license_id = claims
.license_id
.ok_or(ApiError::LicenseInvalid)?;
// Upgrade to WebSocket
Ok(ws.on_upgrade(move |socket| handle_socket(socket, license_id, state)))
}
/// Handle WebSocket connection after upgrade
async fn handle_socket(socket: WebSocket, license_id: uuid::Uuid, state: Arc<AppState>) {
let (mut sender, mut receiver) = socket.split();
// Check if NATS is available
let nats = match &state.nats {
Some(client) => client.clone(),
None => {
tracing::warn!("WebSocket connected but NATS unavailable");
let _ = sender
.send(Message::Text(
serde_json::json!({
"type": "error",
"message": "Event bus unavailable"
})
.to_string()
.into(),
))
.await;
return;
}
};
// Subscribe to license-scoped events
// Pattern: corrosion.{license_id}.> (all events for this license)
let subject = format!("corrosion.{}.*", license_id);
let mut sub = match nats.subscribe(subject.clone()).await {
Ok(s) => s,
Err(e) => {
tracing::error!("Failed to subscribe to NATS: {}", e);
let _ = sender
.send(Message::Text(
serde_json::json!({
"type": "error",
"message": "Failed to subscribe to events"
})
.to_string()
.into(),
))
.await;
return;
}
};
tracing::info!(
"WebSocket connected for license {} (subscribed to {})",
license_id,
subject
);
// Send welcome message
let _ = sender
.send(Message::Text(
serde_json::json!({
"type": "connected",
"license_id": license_id,
"subscribed_to": subject
})
.to_string()
.into(),
))
.await;
// Spawn task to forward NATS messages to WebSocket
let mut send_task = tokio::spawn(async move {
while let Some(msg) = sub.next().await {
let payload = String::from_utf8_lossy(&msg.payload).to_string();
// Parse subject to extract event type
// Format: corrosion.{license_id}.{event_type}
let event_type = msg
.subject
.split('.')
.nth(2)
.unwrap_or("unknown")
.to_string();
// Send to WebSocket client
let ws_msg = serde_json::json!({
"type": "event",
"event": event_type,
"subject": msg.subject.as_str(),
"data": serde_json::from_str::<serde_json::Value>(&payload).ok(),
"raw": payload
});
if sender
.send(Message::Text(ws_msg.to_string().into()))
.await
.is_err()
{
break; // Client disconnected
}
}
});
// Spawn task to handle incoming WebSocket messages (client → NATS)
let nats_clone = nats.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
match msg {
Message::Text(text) => {
// Parse incoming message
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(subject) = parsed.get("subject").and_then(|v| v.as_str()) {
if let Some(data) = parsed.get("data") {
// Publish to NATS
let payload = data.to_string();
if let Err(e) = nats_clone.publish(subject.to_string(), payload.into()).await {
tracing::error!("Failed to publish to NATS: {}", e);
}
}
}
}
}
Message::Close(_) => {
tracing::info!("WebSocket closed by client (license: {})", license_id);
break;
}
// Ping/Pong is handled automatically by Axum
_ => {}
}
}
});
// Wait for either task to complete (connection closed)
tokio::select! {
_ = (&mut send_task) => {
recv_task.abort();
}
_ = (&mut recv_task) => {
send_task.abort();
}
}
tracing::info!("WebSocket connection closed for license {}", license_id);
}