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:
309
plugin/CorrosionCompanion.cs
Normal file
309
plugin/CorrosionCompanion.cs
Normal file
@@ -0,0 +1,309 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Oxide.Core;
|
||||
using Oxide.Core.Libraries.Covalence;
|
||||
|
||||
namespace Oxide.Plugins
|
||||
{
|
||||
[Info("Corrosion Companion", "Corrosion", "1.0.0")]
|
||||
[Description("Connects Rust server to Corrosion admin panel via HTTP API")]
|
||||
public class CorrosionCompanion : RustPlugin
|
||||
{
|
||||
#region Configuration
|
||||
|
||||
private Configuration config;
|
||||
|
||||
public class Configuration
|
||||
{
|
||||
[JsonProperty("API Base URL")]
|
||||
public string ApiBaseUrl { get; set; } = "https://api.corrosion.example.com";
|
||||
|
||||
[JsonProperty("License Key")]
|
||||
public string LicenseKey { get; set; } = "YOUR_LICENSE_KEY_HERE";
|
||||
|
||||
[JsonProperty("Heartbeat Interval (seconds)")]
|
||||
public int HeartbeatInterval { get; set; } = 60;
|
||||
|
||||
[JsonProperty("Send Player Events")]
|
||||
public bool SendPlayerEvents { get; set; } = true;
|
||||
|
||||
[JsonProperty("Send Chat Events")]
|
||||
public bool SendChatEvents { get; set; } = false;
|
||||
|
||||
[JsonProperty("Debug Mode")]
|
||||
public bool DebugMode { get; set; } = false;
|
||||
}
|
||||
|
||||
protected override void LoadConfig()
|
||||
{
|
||||
base.LoadConfig();
|
||||
try
|
||||
{
|
||||
config = Config.ReadObject<Configuration>();
|
||||
if (config == null)
|
||||
{
|
||||
throw new Exception("Config is null");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
PrintWarning("Config file corrupt or missing, generating new one");
|
||||
LoadDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void LoadDefaultConfig()
|
||||
{
|
||||
config = new Configuration();
|
||||
SaveConfig();
|
||||
}
|
||||
|
||||
protected override void SaveConfig() => Config.WriteObject(config);
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lifecycle Hooks
|
||||
|
||||
private Timer heartbeatTimer;
|
||||
|
||||
void OnServerInitialized()
|
||||
{
|
||||
Puts("Corrosion Companion initialized");
|
||||
|
||||
// Validate configuration
|
||||
if (string.IsNullOrEmpty(config.LicenseKey) || config.LicenseKey == "YOUR_LICENSE_KEY_HERE")
|
||||
{
|
||||
PrintError("License key not configured! Edit oxide/config/CorrosionCompanion.json");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send initial check-in
|
||||
CheckIn();
|
||||
|
||||
// Start heartbeat timer
|
||||
heartbeatTimer = timer.Every(config.HeartbeatInterval, () =>
|
||||
{
|
||||
SendHeartbeat();
|
||||
});
|
||||
|
||||
Puts($"Heartbeat started (every {config.HeartbeatInterval}s)");
|
||||
}
|
||||
|
||||
void Unload()
|
||||
{
|
||||
heartbeatTimer?.Destroy();
|
||||
Puts("Corrosion Companion unloaded");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Player Event Hooks
|
||||
|
||||
void OnPlayerConnected(BasePlayer player)
|
||||
{
|
||||
if (!config.SendPlayerEvents) return;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "event", "player_connected" },
|
||||
{ "player_id", player.UserIDString },
|
||||
{ "player_name", player.displayName },
|
||||
{ "ip_address", player.net?.connection?.ipaddress ?? "unknown" },
|
||||
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
||||
};
|
||||
|
||||
SendEvent("player_connected", data);
|
||||
|
||||
if (config.DebugMode)
|
||||
{
|
||||
Puts($"Player connected: {player.displayName} ({player.UserIDString})");
|
||||
}
|
||||
}
|
||||
|
||||
void OnPlayerDisconnected(BasePlayer player, string reason)
|
||||
{
|
||||
if (!config.SendPlayerEvents) return;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "event", "player_disconnected" },
|
||||
{ "player_id", player.UserIDString },
|
||||
{ "player_name", player.displayName },
|
||||
{ "reason", reason ?? "unknown" },
|
||||
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
||||
};
|
||||
|
||||
SendEvent("player_disconnected", data);
|
||||
|
||||
if (config.DebugMode)
|
||||
{
|
||||
Puts($"Player disconnected: {player.displayName} (Reason: {reason})");
|
||||
}
|
||||
}
|
||||
|
||||
object OnPlayerChat(BasePlayer player, string message)
|
||||
{
|
||||
if (!config.SendChatEvents) return null;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "event", "player_chat" },
|
||||
{ "player_id", player.UserIDString },
|
||||
{ "player_name", player.displayName },
|
||||
{ "message", message },
|
||||
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
||||
};
|
||||
|
||||
SendEvent("player_chat", data);
|
||||
|
||||
return null; // Don't block the message
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region API Communication
|
||||
|
||||
private void CheckIn()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "license_key", config.LicenseKey },
|
||||
{ "server_name", ConVar.Server.hostname },
|
||||
{ "server_description", ConVar.Server.description },
|
||||
{ "server_url", ConVar.Server.url },
|
||||
{ "max_players", ConVar.Server.maxplayers },
|
||||
{ "world_size", ConVar.Server.worldsize },
|
||||
{ "seed", ConVar.Server.seed },
|
||||
{ "plugin_version", Version.ToString() },
|
||||
{ "server_version", Rust.Protocol.network.ToString() }
|
||||
};
|
||||
|
||||
SendApiRequest("/api/plugin/checkin", data, (code, response) =>
|
||||
{
|
||||
if (code == 200)
|
||||
{
|
||||
Puts("Check-in successful");
|
||||
|
||||
if (config.DebugMode)
|
||||
{
|
||||
Puts($"Response: {response}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
PrintWarning($"Check-in failed: HTTP {code}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void SendHeartbeat()
|
||||
{
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "license_key", config.LicenseKey },
|
||||
{ "player_count", BasePlayer.activePlayerList.Count },
|
||||
{ "max_players", ConVar.Server.maxplayers },
|
||||
{ "fps", Performance.current.frameRate },
|
||||
{ "entity_count", BaseNetworkable.serverEntities.Count },
|
||||
{ "uptime_seconds", Time.realtimeSinceStartup },
|
||||
{ "timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds() }
|
||||
};
|
||||
|
||||
SendApiRequest("/api/plugin/heartbeat", data, (code, response) =>
|
||||
{
|
||||
if (config.DebugMode)
|
||||
{
|
||||
if (code == 200)
|
||||
{
|
||||
Puts($"Heartbeat sent (Players: {BasePlayer.activePlayerList.Count}, FPS: {Performance.current.frameRate:F1})");
|
||||
}
|
||||
else
|
||||
{
|
||||
PrintWarning($"Heartbeat failed: HTTP {code}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void SendEvent(string eventType, Dictionary<string, object> data)
|
||||
{
|
||||
data["license_key"] = config.LicenseKey;
|
||||
|
||||
SendApiRequest($"/api/plugin/events/{eventType}", data, (code, response) =>
|
||||
{
|
||||
if (config.DebugMode && code != 200)
|
||||
{
|
||||
PrintWarning($"Event {eventType} failed: HTTP {code}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void SendApiRequest(string endpoint, Dictionary<string, object> data, Action<int, string> callback)
|
||||
{
|
||||
string url = config.ApiBaseUrl.TrimEnd('/') + endpoint;
|
||||
string json = JsonConvert.SerializeObject(data);
|
||||
|
||||
webrequest.Enqueue(url, json, (code, response) =>
|
||||
{
|
||||
callback?.Invoke(code, response ?? "");
|
||||
}, this, RequestMethod.POST, new Dictionary<string, string>
|
||||
{
|
||||
{ "Content-Type", "application/json" }
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Console Commands
|
||||
|
||||
[Command("corrosion.status")]
|
||||
private void StatusCommand(IPlayer player, string command, string[] args)
|
||||
{
|
||||
if (!player.IsAdmin)
|
||||
{
|
||||
player.Reply("You don't have permission to use this command");
|
||||
return;
|
||||
}
|
||||
|
||||
player.Reply("=== Corrosion Companion Status ===");
|
||||
player.Reply($"Version: {Version}");
|
||||
player.Reply($"License Key: {config.LicenseKey.Substring(0, Math.Min(8, config.LicenseKey.Length))}...");
|
||||
player.Reply($"API URL: {config.ApiBaseUrl}");
|
||||
player.Reply($"Heartbeat Interval: {config.HeartbeatInterval}s");
|
||||
player.Reply($"Player Events: {(config.SendPlayerEvents ? "Enabled" : "Disabled")}");
|
||||
player.Reply($"Chat Events: {(config.SendChatEvents ? "Enabled" : "Disabled")}");
|
||||
player.Reply($"Debug Mode: {(config.DebugMode ? "Enabled" : "Disabled")}");
|
||||
player.Reply($"Active Players: {BasePlayer.activePlayerList.Count}");
|
||||
}
|
||||
|
||||
[Command("corrosion.checkin")]
|
||||
private void CheckinCommand(IPlayer player, string command, string[] args)
|
||||
{
|
||||
if (!player.IsAdmin)
|
||||
{
|
||||
player.Reply("You don't have permission to use this command");
|
||||
return;
|
||||
}
|
||||
|
||||
player.Reply("Sending check-in to Corrosion API...");
|
||||
CheckIn();
|
||||
}
|
||||
|
||||
[Command("corrosion.heartbeat")]
|
||||
private void HeartbeatCommand(IPlayer player, string command, string[] args)
|
||||
{
|
||||
if (!player.IsAdmin)
|
||||
{
|
||||
player.Reply("You don't have permission to use this command");
|
||||
return;
|
||||
}
|
||||
|
||||
player.Reply("Sending heartbeat to Corrosion API...");
|
||||
SendHeartbeat();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user