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:
@@ -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
185
backend/src/api/ws.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user