Files
corrosion-admin-panel/plugin/CorrosionCompanion.cs
Vantz Stockwell 590765fbbc 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>
2026-02-15 12:07:01 -05:00

310 lines
10 KiB
C#

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
}
}