C#:
using System;
using System.IO;
using System.Linq;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics;
using System.Net.NetworkInformation;
using System.Security.Cryptography;
using System.Text;
using Rust;
using Facepunch;
using UnityEngine;
using Newtonsoft.Json;
using Oxide.Core.Plugins;
using Oxide.Core.Libraries;
using Oxide.Core.Libraries.Covalence;
namespace Oxide.Plugins
{
[Info("ServerMonitor", "DeltaDinizzz", "0.1.0")]
public class ServerMonitor : CovalencePlugin
{
// ##StartModule - Global Variables & Config Properties
private static ServerMonitor _instance;
private FPSVisor ActiveVisor;
private int MinimalFPS = 9999;
// --- CONFIG ---
private const string ApiUrl = "https://rustcenter.org/api/server-monitor/ingest";
// --- ACCESS TOKENS ---
// Three tokens are generated, one per console access level. The one the owner registers on
// the website becomes ActiveToken; the other two are discarded. See "Access Tokens" below.
private const string LevelFull = "full";
private const string LevelStandard = "standard";
private const string LevelRestricted = "restricted";
private const float AwaitingRegistrationRetrySeconds = 15.0f;
private DateTime _lastAwaitingLogUtc = DateTime.MinValue;
private string ActiveToken => Config["ActiveToken"] as string ?? string.Empty;
private string ActiveLevel => Config["ActiveLevel"] as string ?? string.Empty;
private bool IsActivated => !string.IsNullOrEmpty(ActiveToken);
// --- DYNAMIC INTERVALS (Provided by the Web API) ---
private float _updateInterval = 2.0f;
private string _monitorMode = "adaptive"; // adaptive | always_on
private bool _shouldCollectFull = true;
private Timer _loopTimer;
private bool _isSleeping = false;
private bool _hasUnloaded = false;
private const float MinErrorRetrySeconds = 10.0f;
private readonly Dictionary<string, double> _lastHookSecondsByPlugin = new Dictionary<string, double>();
private readonly Dictionary<string, double> _maxHookSinceLastTickSecondsByPlugin = new Dictionary<string, double>();
private DateTime _lastFullHookSampleUtc = DateTime.MinValue;
// CPU sampling (delta between ticks)
private DateTime _lastCpuTimeUtc = DateTime.MinValue;
private TimeSpan _lastProcessorTime = TimeSpan.Zero;
private DateTime _lastNetworkSampleUtc = DateTime.MinValue;
private long _lastBytesReceived = -1;
private long _lastBytesSent = -1;
// ##EndModule - Global Variables & Config Properties
// ##StartModule - Oxide Hooks
protected override void LoadDefaultConfig()
{
GenerateAccessTokens();
}
private void Init()
{
_instance = this;
_hasUnloaded = false;
MigrateTokenConfig();
PrintTokenBanner();
}
private void OnServerInitialized()
{
// Adiciona o script para monitorar o menor FPS
ActiveVisor = Terrain.activeTerrain.gameObject.AddComponent<FPSVisor>();
// Kickstart no loop
SendServerStatsTick();
}
private void Unload()
{
_hasUnloaded = true;
if (ActiveVisor != null)
{
UnityEngine.Object.Destroy(ActiveVisor);
}
if (_loopTimer != null)
{
_loopTimer.Destroy();
}
}
// ##EndModule - Oxide Hooks
// ##StartModule - Core Server Tick Logic
private void SendServerStatsTick()
{
if (_hasUnloaded)
return;
// ── HEARTBEAT MODE ──
// When sleeping in adaptive mode, send only a lightweight ping
// so the API can tell us when to wake up. No data collection.
// Until one of the tokens is registered on the website, only heartbeats are sent.
if (!IsActivated || (_isSleeping && _monitorMode != "always_on"))
{
// Reset baselines so the first "full" tick after waking
// doesn't retroactively cover the entire sleep period.
_lastHookSecondsByPlugin.Clear();
_maxHookSinceLastTickSecondsByPlugin.Clear();
_lastFullHookSampleUtc = DateTime.MinValue;
_lastCpuTimeUtc = DateTime.MinValue;
_lastNetworkSampleUtc = DateTime.MinValue;
_lastBytesReceived = -1;
_lastBytesSent = -1;
// Before registration the website only gets the hashes of the 3 tokens (never the
// tokens), so it can only activate the one the owner actually registered there
object heartbeat = IsActivated
? (object)new { method = "heartbeat", token = ActiveToken }
: new { method = "heartbeat", tokenHashes = PendingTokenHashes() };
string heartbeatJson = JsonConvert.SerializeObject(heartbeat);
var hbHeaders = new Dictionary<string, string> { { "Content-Type", "application/json" } };
webrequest.Enqueue(ApiUrl, heartbeatJson, (code, response) =>
{
if (_hasUnloaded) return;
if (code == 200 || code == 204)
{
try
{
var jr = JsonConvert.DeserializeObject<Dictionary<string, object>>(response);
if (jr != null)
{
if (!IsActivated && jr.ContainsKey("activate") && jr["activate"] != null)
TryActivateToken(jr["activate"].ToString());
if (jr.ContainsKey("updateInterval"))
float.TryParse(jr["updateInterval"].ToString(), out _updateInterval);
if (jr.ContainsKey("monitorMode"))
_monitorMode = jr["monitorMode"].ToString();
if (jr.ContainsKey("shouldCollectFull"))
{
bool parsed;
if (bool.TryParse(jr["shouldCollectFull"].ToString(), out parsed))
_shouldCollectFull = parsed;
}
if (jr.ContainsKey("state"))
{
string st = jr["state"].ToString();
if (st == "active")
{
Puts($"[ServerMonitor] Dashboard aberta/detectada. Saindo do Sleep Mode! Atualizando a cada {_updateInterval}s...");
_isSleeping = false;
}
}
if (_monitorMode == "always_on")
_isSleeping = false;
}
}
catch { }
}
else if (!IsActivated && (DateTime.UtcNow - _lastAwaitingLogUtc).TotalMinutes >= 5)
{
// The website answers 403 until one of the tokens is registered there
_lastAwaitingLogUtc = DateTime.UtcNow;
Puts("[ServerMonitor] Waiting for one of the 3 tokens to be registered at rustcenter.org (run 'servermonitor.tokens' in the server console to see them).");
}
if (!_hasUnloaded)
_loopTimer = timer.Once(IsActivated ? _updateInterval : AwaitingRegistrationRetrySeconds, SendServerStatsTick);
}, this, RequestMethod.POST, hbHeaders);
return;
}
// ── FULL DATA COLLECTION ──
var nowUtc = DateTime.UtcNow;
var connectedPlayers = players.Connected.ToArray();
var pluginItems = new List<object>();
double? sampleWindowMs = null;
if (_lastFullHookSampleUtc != DateTime.MinValue && _lastFullHookSampleUtc < nowUtc)
sampleWindowMs = Math.Max(0.0, (nowUtc - _lastFullHookSampleUtc).TotalMilliseconds);
_lastFullHookSampleUtc = nowUtc;
// Coleta plugins
var listPlugins = plugins.PluginManager.GetPlugins().ToArray();
var seenPluginNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < listPlugins.Length; i++)
{
var pluginName = listPlugins[i].Name;
seenPluginNames.Add(pluginName);
var hookSeconds = listPlugins[i].TotalHookTime.TotalSeconds;
double previousHookSeconds;
var hasPreviousHookSample = _lastHookSecondsByPlugin.TryGetValue(pluginName, out previousHookSeconds);
var hookCounterReset = hasPreviousHookSample && hookSeconds < previousHookSeconds;
var deltaHookSeconds = (!hasPreviousHookSample || hookCounterReset)
? 0.0
: Math.Max(0.0, hookSeconds - previousHookSeconds);
double previousMaxSinceLastTick;
var hasPreviousMax = _maxHookSinceLastTickSecondsByPlugin.TryGetValue(pluginName, out previousMaxSinceLastTick);
var nextMaxSinceLastTick = (!hasPreviousHookSample || hookCounterReset)
? 0.0
: Math.Max(hasPreviousMax ? previousMaxSinceLastTick : 0.0, deltaHookSeconds);
_maxHookSinceLastTickSecondsByPlugin[pluginName] = nextMaxSinceLastTick;
_lastHookSecondsByPlugin[pluginName] = hookSeconds;
pluginItems.Add(new
{
name = pluginName,
version = listPlugins[i].Version.ToString(),
author = listPlugins[i].Author,
hash = pluginName.GetHashCode(),
time = deltaHookSeconds,
hookTimeMs = deltaHookSeconds * 1000.0,
hookMaxSinceLastTickMs = nextMaxSinceLastTick * 1000.0
});
_maxHookSinceLastTickSecondsByPlugin[pluginName] = 0.0;
}
PruneStaleHookSamples(seenPluginNames);
int currentMinFps = MinimalFPS;
MinimalFPS = 9999; // Reseta depois de ler
// CPU, RAM e Disk (uso real do processo / disco)
double cpuPercent = 0;
long ramMb = 0;
long ramTotalMb = 0;
double diskUsedPercent = -1;
double? averagePingMs = null;
double? networkInKBps = null;
double? networkOutKBps = null;
try
{
ramMb = GetProcessRamMb();
ramTotalMb = GetTotalSystemRamMb();
// CPU: % desde o último tick (TotalProcessorTime delta / wall-clock delta)
var process = Process.GetCurrentProcess();
process.Refresh();
var currentProcessorTime = process.TotalProcessorTime;
if (_lastCpuTimeUtc != DateTime.MinValue && _lastCpuTimeUtc < nowUtc)
{
var wallSeconds = (nowUtc - _lastCpuTimeUtc).TotalSeconds;
var cpuSeconds = (currentProcessorTime - _lastProcessorTime).TotalSeconds;
if (wallSeconds > 0 && cpuSeconds >= 0)
cpuPercent = (cpuSeconds / wallSeconds) * 100.0; // Pode passar 100 em multi-core
}
_lastCpuTimeUtc = nowUtc;
_lastProcessorTime = currentProcessorTime;
// Disk: % usado no drive onde o servidor está a correr
try
{
var baseDir = AppDomain.CurrentDomain.BaseDirectory;
if (!string.IsNullOrEmpty(baseDir))
{
var root = Path.GetPathRoot(baseDir);
if (!string.IsNullOrEmpty(root))
{
var drive = new DriveInfo(root);
if (drive.IsReady)
{
long total = drive.TotalSize;
long free = drive.AvailableFreeSpace;
if (total > 0)
diskUsedPercent = (double)(total - free) / total * 100.0;
}
}
}
}
catch { diskUsedPercent = -1; }
}
catch { /* Process/Refresh pode falhar em alguns ambientes */ }
averagePingMs = GetAveragePlayerPingMs(connectedPlayers);
SampleNetworkIo(nowUtc, out networkInKBps, out networkOutKBps);
// Monta o Payload principal
var payload = new
{
method = "tick_server",
serverName = server.Name,
serverIp = server.Address + ":" + server.Port,
token = ActiveToken,
fps = Performance.current.frameRate,
minfps = currentMinFps,
ent = BaseNetworkable.serverEntities.Count,
online = connectedPlayers.Length,
maxPlayers = server.MaxPlayers,
SleepPlayer = BasePlayer.sleepingPlayerList.Count,
JoiningPlayer = ServerMgr.Instance.connectionQueue.Joining,
QueuedPlayer = ServerMgr.Instance.connectionQueue.Queued,
uptime = (int)UnityEngine.Time.realtimeSinceStartup,
version = server.Version,
map = ConVar.Server.level,
sampleWindowMs = sampleWindowMs.HasValue ? Math.Round(sampleWindowMs.Value) : (double?)null,
listPlugins = pluginItems,
isSleeping = _isSleeping,
cpu = Math.Round(cpuPercent, 2),
ramMb = ramMb,
ramTotalMb = ramTotalMb > 0 ? ramTotalMb : (long?)null,
ping = averagePingMs.HasValue ? Math.Round(averagePingMs.Value, 2) : (double?)null,
diskUsedPercent = diskUsedPercent >= 0 ? Math.Round(diskUsedPercent, 2) : (double?)null,
networkInKBps = networkInKBps.HasValue ? Math.Round(networkInKBps.Value, 2) : (double?)null,
networkOutKBps = networkOutKBps.HasValue ? Math.Round(networkOutKBps.Value, 2) : (double?)null
};
string jsonPayload = JsonConvert.SerializeObject(payload);
// Cabeçalho básico
var headers = new Dictionary<string, string>
{
{ "Content-Type", "application/json" }
};
// Envia o HTTP POST via WebRequest
webrequest.Enqueue(ApiUrl, jsonPayload, (code, response) =>
{
if (_hasUnloaded)
return;
float nextDelay = _updateInterval;
if (code == 200 || code == 204)
{
try
{
var jsonResponse = JsonConvert.DeserializeObject<Dictionary<string, object>>(response);
if (jsonResponse != null)
{
if (jsonResponse.ContainsKey("updateInterval"))
{
float.TryParse(jsonResponse["updateInterval"].ToString(), out _updateInterval);
nextDelay = _updateInterval;
}
if (jsonResponse.ContainsKey("monitorMode"))
{
_monitorMode = jsonResponse["monitorMode"].ToString();
}
if (jsonResponse.ContainsKey("shouldCollectFull"))
{
bool parsedCollectFull;
if (bool.TryParse(jsonResponse["shouldCollectFull"].ToString(), out parsedCollectFull))
{
_shouldCollectFull = parsedCollectFull;
}
}
if (jsonResponse.ContainsKey("state"))
{
string state = jsonResponse["state"].ToString();
if (state == "sleep")
{
if (!_isSleeping)
Puts($"[ServerMonitor] Ninguém na dashboard. Entrando em Sleep Mode (heartbeat a cada {_updateInterval}s)...");
_isSleeping = true;
}
else if (state == "active")
{
if (_isSleeping)
Puts($"[ServerMonitor] Dashboard aberta/detectada. Saindo do Sleep Mode! Atualizando a cada {_updateInterval}s...");
_isSleeping = false;
}
else if (state == "degraded")
{
_isSleeping = false;
nextDelay = Math.Max(_updateInterval, MinErrorRetrySeconds);
}
}
if (_monitorMode == "always_on")
{
_isSleeping = false;
}
// C# Native RCON-less execution
if (jsonResponse.ContainsKey("pendingCommands"))
{
var commandsList = jsonResponse["pendingCommands"] as Newtonsoft.Json.Linq.JArray;
if (commandsList != null)
{
foreach (var command in commandsList)
{
string cmdRaw = command.ToString();
if (string.IsNullOrEmpty(cmdRaw))
continue;
// Enforced here, not only on the website: a website problem can
// never go above the access level of the registered token
string blockedReason;
if (!IsCommandAllowed(ActiveLevel, cmdRaw, out blockedReason))
{
PrintWarning($"[ServerMonitor] Blocked remote command ({LevelLabel(ActiveLevel)} access): {cmdRaw} ({blockedReason})");
continue;
}
Puts($"[ServerMonitor] Executing remote command: {cmdRaw}");
ConsoleSystem.Run(ConsoleSystem.Option.Server, cmdRaw);
}
}
}
}
}
catch { }
}
else
{
// Em erro transitório de rede/proxy, aplica backoff curto.
float retryDelay = Math.Max(_updateInterval * 2.0f, MinErrorRetrySeconds);
Puts($"[ServerMonitor] HTTP Error {code} - Response: {(response ?? "null")}. Retrying in {retryDelay:0.0}s...");
_isSleeping = false;
nextDelay = retryDelay;
}
// Agenda a próxima execução
if (!_hasUnloaded)
_loopTimer = timer.Once(nextDelay, SendServerStatsTick);
}, this, RequestMethod.POST, headers);
}
// ##EndModule - Core Server Tick Logic
private long GetProcessRamMb()
{
try
{
var process = Process.GetCurrentProcess();
process.Refresh();
long workingSetBytes = process.WorkingSet64;
if (workingSetBytes > 0)
return workingSetBytes / (1024L * 1024L);
}
catch { }
long procStatusKb;
if (TryReadProcFileValueKb("/proc/self/status", "VmRSS:", out procStatusKb) && procStatusKb > 0)
return procStatusKb / 1024L;
try
{
var process = Process.GetCurrentProcess();
string processStatusPath = $"/proc/{process.Id}/status";
if (TryReadProcFileValueKb(processStatusPath, "VmRSS:", out procStatusKb) && procStatusKb > 0)
return procStatusKb / 1024L;
}
catch { }
long residentPages;
if (TryReadProcStatmResidentPages(out residentPages) && residentPages > 0)
{
long pageSizeBytes = Environment.SystemPageSize > 0 ? Environment.SystemPageSize : 4096;
long residentBytes = residentPages * pageSizeBytes;
if (residentBytes > 0)
return residentBytes / (1024L * 1024L);
}
try
{
long managedBytes = System.GC.GetTotalMemory(false);
if (managedBytes > 0)
return managedBytes / (1024L * 1024L);
}
catch { }
return 0;
}
private long GetTotalSystemRamMb()
{
long memTotalKb;
if (TryReadProcFileValueKb("/proc/meminfo", "MemTotal:", out memTotalKb) && memTotalKb > 0)
return memTotalKb / 1024L;
try
{
var unitySystemMemoryMb = SystemInfo.systemMemorySize;
if (unitySystemMemoryMb > 0)
return unitySystemMemoryMb;
}
catch { }
return 0;
}
private bool TryReadProcFileValueKb(string path, string key, out long valueKb)
{
valueKb = 0;
try
{
if (!File.Exists(path))
return false;
var lines = File.ReadAllLines(path);
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
if (!line.StartsWith(key, StringComparison.OrdinalIgnoreCase))
continue;
var digits = new string(line.Where(char.IsDigit).ToArray());
long parsed;
if (long.TryParse(digits, out parsed) && parsed >= 0)
{
valueKb = parsed;
return true;
}
}
}
catch { }
return false;
}
private bool TryReadProcStatmResidentPages(out long residentPages)
{
residentPages = 0;
try
{
const string statmPath = "/proc/self/statm";
if (!File.Exists(statmPath))
return false;
var content = File.ReadAllText(statmPath).Trim();
if (string.IsNullOrEmpty(content))
return false;
var parts = content.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
return false;
long parsedPages;
if (long.TryParse(parts[1], out parsedPages) && parsedPages > 0)
{
residentPages = parsedPages;
return true;
}
}
catch { }
return false;
}
private double? GetAveragePlayerPingMs(IPlayer[] connectedPlayers)
{
try
{
if (connectedPlayers == null || connectedPlayers.Length == 0)
return null;
double totalPing = 0;
int samples = 0;
for (int i = 0; i < connectedPlayers.Length; i++)
{
var ping = connectedPlayers[i].Ping;
if (ping < 0)
continue;
totalPing += ping;
samples++;
}
if (samples == 0)
return null;
return totalPing / samples;
}
catch { }
return null;
}
private void SampleNetworkIo(DateTime nowUtc, out double? networkInKBps, out double? networkOutKBps)
{
networkInKBps = null;
networkOutKBps = null;
long bytesReceived;
long bytesSent;
if (!TryGetNetworkTotals(out bytesReceived, out bytesSent))
return;
if (_lastNetworkSampleUtc == DateTime.MinValue || _lastBytesReceived < 0 || _lastBytesSent < 0)
{
networkInKBps = 0;
networkOutKBps = 0;
}
else if (_lastNetworkSampleUtc < nowUtc)
{
var wallSeconds = (nowUtc - _lastNetworkSampleUtc).TotalSeconds;
var deltaReceived = bytesReceived - _lastBytesReceived;
var deltaSent = bytesSent - _lastBytesSent;
if (wallSeconds > 0 && deltaReceived >= 0 && deltaSent >= 0)
{
networkInKBps = (deltaReceived / 1024.0) / wallSeconds;
networkOutKBps = (deltaSent / 1024.0) / wallSeconds;
}
}
_lastNetworkSampleUtc = nowUtc;
_lastBytesReceived = bytesReceived;
_lastBytesSent = bytesSent;
}
private bool TryGetNetworkTotals(out long bytesReceived, out long bytesSent)
{
bytesReceived = 0;
bytesSent = 0;
if (TryGetNetworkTotalsDotNet(out bytesReceived, out bytesSent))
return true;
if (TryGetNetworkTotalsProc(out bytesReceived, out bytesSent))
return true;
return TryGetNetworkTotalsNetstat(out bytesReceived, out bytesSent);
}
private bool TryGetNetworkTotalsDotNet(out long bytesReceived, out long bytesSent)
{
bytesReceived = 0;
bytesSent = 0;
try
{
var interfaces = NetworkInterface.GetAllNetworkInterfaces();
for (int i = 0; i < interfaces.Length; i++)
{
var networkInterface = interfaces[i];
if (networkInterface == null)
continue;
if (networkInterface.OperationalStatus != OperationalStatus.Up)
continue;
if (networkInterface.NetworkInterfaceType == NetworkInterfaceType.Loopback || networkInterface.NetworkInterfaceType == NetworkInterfaceType.Tunnel)
continue;
var stats = networkInterface.GetIPStatistics();
if (stats == null)
continue;
bytesReceived += stats.BytesReceived;
bytesSent += stats.BytesSent;
}
return true;
}
catch { }
return false;
}
private bool TryGetNetworkTotalsNetstat(out long bytesReceived, out long bytesSent)
{
bytesReceived = 0;
bytesSent = 0;
try
{
var startInfo = new ProcessStartInfo
{
FileName = "netstat",
Arguments = "-e",
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using (var process = Process.Start(startInfo))
{
if (process == null)
return false;
var output = process.StandardOutput.ReadToEnd();
process.WaitForExit(2000);
if (string.IsNullOrWhiteSpace(output))
return false;
var lines = output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
for (int i = 0; i < lines.Length; i++)
{
var line = lines[i];
var parts = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2)
continue;
long received;
long sent;
if (!long.TryParse(parts[parts.Length - 2], out received) || !long.TryParse(parts[parts.Length - 1], out sent))
continue;
if (received < 0 || sent < 0)
continue;
bytesReceived = received;
bytesSent = sent;
return true;
}
}
}
catch { }
return false;
}
private bool TryGetNetworkTotalsProc(out long bytesReceived, out long bytesSent)
{
bytesReceived = 0;
bytesSent = 0;
bool parsedAnyInterface = false;
try
{
const string procNetDevPath = "/proc/net/dev";
if (!File.Exists(procNetDevPath))
return false;
var lines = File.ReadAllLines(procNetDevPath);
for (int i = 2; i < lines.Length; i++)
{
var line = lines[i];
if (string.IsNullOrWhiteSpace(line) || line.IndexOf(':') < 0)
continue;
var split = line.Split(new[] { ':' }, 2);
if (split.Length != 2)
continue;
var interfaceName = split[0].Trim();
if (interfaceName == "lo")
continue;
var values = split[1].Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
if (values.Length < 16)
continue;
long received;
long sent;
if (!long.TryParse(values[0], out received) || !long.TryParse(values[8], out sent))
continue;
parsedAnyInterface = true;
bytesReceived += received;
bytesSent += sent;
}
return parsedAnyInterface;
}
catch { }
return false;
}
private void PruneStaleHookSamples(HashSet<string> seenPluginNames)
{
if (seenPluginNames == null || seenPluginNames.Count == 0)
{
_lastHookSecondsByPlugin.Clear();
_maxHookSinceLastTickSecondsByPlugin.Clear();
return;
}
var lastKeys = _lastHookSecondsByPlugin.Keys.ToArray();
for (int i = 0; i < lastKeys.Length; i++)
{
if (!seenPluginNames.Contains(lastKeys[i]))
_lastHookSecondsByPlugin.Remove(lastKeys[i]);
}
var maxKeys = _maxHookSinceLastTickSecondsByPlugin.Keys.ToArray();
for (int i = 0; i < maxKeys.Length; i++)
{
if (!seenPluginNames.Contains(maxKeys[i]))
_maxHookSinceLastTickSecondsByPlugin.Remove(maxKeys[i]);
}
}
// ##StartModule - Access Tokens
// Three tokens, one per console access level. The owner registers ONE at rustcenter.org; the
// website then answers "activate" with that token (it never learns the other two, which are
// sent only as SHA-256 hashes), and this plugin keeps it and discards the others. Remote
// commands are checked against the level here, so the website can't go above it.
private void GenerateAccessTokens()
{
Config["TokenFull"] = "SM-FULL-" + Guid.NewGuid().ToString("N");
Config["TokenStandard"] = "SM-STD-" + Guid.NewGuid().ToString("N");
Config["TokenRestricted"] = "SM-LTD-" + Guid.NewGuid().ToString("N");
Config["ActiveToken"] = string.Empty;
Config["ActiveLevel"] = string.Empty;
SaveConfig();
}
private void MigrateTokenConfig()
{
// Plugin <= 0.0.4 had a single token, which always had full access: keep that
// registration working as the active FULL token (servermonitor.newtokens changes it)
var legacyToken = Config["ServerToken"] as string;
if (!IsActivated && !string.IsNullOrEmpty(legacyToken))
{
Config["ActiveToken"] = legacyToken;
Config["ActiveLevel"] = LevelFull;
Config["ServerToken"] = string.Empty;
SaveConfig();
return;
}
bool hasPendingTokens = !string.IsNullOrEmpty(Config["TokenFull"] as string)
&& !string.IsNullOrEmpty(Config["TokenStandard"] as string)
&& !string.IsNullOrEmpty(Config["TokenRestricted"] as string);
if (!IsActivated && !hasPendingTokens)
GenerateAccessTokens();
}
private void PrintTokenBanner()
{
Puts("======================================================");
Puts($" RustCenter Server Monitor {Version}");
if (IsActivated)
{
Puts($" Linked to rustcenter.org with {LevelLabel(ActiveLevel)} console access (token {MaskToken(ActiveToken)}).");
Puts(" Run 'servermonitor.tokens' in the server console for details.");
}
else
{
Puts(" Not linked yet. Register ONE of these tokens at rustcenter.org (Server Monitor):");
Puts($" FULL {Config["TokenFull"]} everything");
Puts($" STANDARD {Config["TokenStandard"]} everything except ownerid/moderatorid, permission changes and quit");
Puts($" RESTRICTED {Config["TokenRestricted"]} only say, server.save and read-only commands");
Puts(" The token you register sets what the website console can do; the other two are discarded.");
}
Puts("======================================================");
}
private string[] PendingTokenHashes()
{
return new[] { "TokenFull", "TokenStandard", "TokenRestricted" }
.Select(key => Config[key] as string)
.Where(token => !string.IsNullOrEmpty(token))
.Select(Sha256Hex)
.ToArray();
}
private void TryActivateToken(string token)
{
string level = null;
if (!string.IsNullOrEmpty(token))
{
if (token == Config["TokenFull"] as string) level = LevelFull;
else if (token == Config["TokenStandard"] as string) level = LevelStandard;
else if (token == Config["TokenRestricted"] as string) level = LevelRestricted;
}
if (level == null)
{
PrintWarning("[ServerMonitor] The website asked to activate a token this server did not generate. Ignored.");
return;
}
Config["ActiveToken"] = token;
Config["ActiveLevel"] = level;
Config["TokenFull"] = string.Empty;
Config["TokenStandard"] = string.Empty;
Config["TokenRestricted"] = string.Empty;
SaveConfig();
Puts($"[ServerMonitor] Linked to rustcenter.org with {LevelLabel(level)} console access. The other 2 tokens were discarded.");
}
[Command("servermonitor.tokens")]
private void CmdTokens(IPlayer player, string command, string[] args)
{
if (!player.IsServer)
{
player.Reply("This command can only be run from the server console.");
return;
}
if (IsActivated)
{
Puts($"[ServerMonitor] Active token ({LevelLabel(ActiveLevel)} access): {ActiveToken}");
Puts("[ServerMonitor] To use another access level, run 'servermonitor.newtokens' and register one of the new tokens on the website.");
}
else
{
PrintTokenBanner();
}
}
[Command("servermonitor.newtokens")]
private void CmdNewTokens(IPlayer player, string command, string[] args)
{
if (!player.IsServer)
{
player.Reply("This command can only be run from the server console.");
return;
}
GenerateAccessTokens();
Puts("[ServerMonitor] New tokens generated. The website entry linked to the old token stops receiving data; register one of the new tokens there.");
PrintTokenBanner();
}
private static string LevelLabel(string level)
{
if (level == LevelFull) return "FULL";
if (level == LevelStandard) return "STANDARD";
if (level == LevelRestricted) return "RESTRICTED";
return "NO";
}
private static string MaskToken(string token)
{
int dash = token.LastIndexOf('-');
return dash >= 0 && token.Length > dash + 6 ? token.Substring(0, dash + 7) + "…" : "…";
}
private static string Sha256Hex(string value)
{
using (var sha = SHA256.Create())
{
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value));
var hex = new StringBuilder(bytes.Length * 2);
foreach (var b in bytes)
hex.Append(b.ToString("x2"));
return hex.ToString();
}
}
// Same rules as the website (lib/server-monitor/commandPolicy.ts)
private static readonly HashSet<string> StandardBlockedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"ownerid", "moderatorid", "removeowner", "removemoderator", "quit"
};
private static readonly HashSet<string> PermissionPrefixes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"c", "carbon", "o", "oxide", "umod"
};
private static readonly HashSet<string> PermissionVerbs = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"grant", "revoke", "group", "usergroup"
};
private static readonly HashSet<string> RestrictedAllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"say", "server.save",
"status", "players", "playerlist", "serverinfo", "fps", "version",
"banlist", "banlistex", "listid",
"c.plugins", "o.plugins", "oxide.plugins", "c.version", "o.version", "oxide.version"
};
/// <summary>Command name as the console resolves it: first word, lowercase, no quotes, no "global.".</summary>
private static string CommandName(string raw)
{
var trimmed = raw.Trim();
int end = 0;
while (end < trimmed.Length && !char.IsWhiteSpace(trimmed[end]))
end++;
var name = trimmed.Substring(0, end).Trim('"', '\'').ToLowerInvariant();
return name.StartsWith("global.") ? name.Substring("global.".Length) : name;
}
private static bool IsPermissionCommand(string name)
{
int dot = name.IndexOf('.');
return dot > 0
&& PermissionPrefixes.Contains(name.Substring(0, dot))
&& PermissionVerbs.Contains(name.Substring(dot + 1));
}
private static bool IsCommandAllowed(string level, string raw, out string reason)
{
reason = null;
if (level == LevelFull)
return true;
// One command per line: ";" or control characters could smuggle a second one in
if (raw.IndexOf(';') >= 0 || raw.Any(char.IsControl))
{
reason = "one command at a time, without ';' or line breaks";
return false;
}
var name = CommandName(raw);
if (level == LevelStandard)
{
if (StandardBlockedCommands.Contains(name) || IsPermissionCommand(name))
{
reason = "needs the FULL access token";
return false;
}
return true;
}
if (level == LevelRestricted)
{
if (RestrictedAllowedCommands.Contains(name))
return true;
reason = "RESTRICTED access only allows say, server.save and read-only commands";
return false;
}
reason = "no access level (server not linked)";
return false;
}
// ##EndModule - Access Tokens
// ##StartModule - FPSVisor Component
public class FPSVisor : MonoBehaviour
{
private void Update()
{
if (_instance.MinimalFPS > (int)global::Performance.current.frameRate)
{
_instance.MinimalFPS = (int)global::Performance.current.frameRate;
}
}
}
// ##EndModule - FPSVisor Component
}
}