// SentinelID — Oxide/uMod Plugin for Rust // // Hardware device verification and whitelist enforcement for Rust servers. // Works alongside the SentinelID sidecar which handles device verification // via the SentinelID API. // // Players must authenticate via the companion app before joining. // Unauthenticated players are kicked after a grace period. // // Installation: // 1. Run the SentinelID sidecar on your server (see tools/rust-sidecar/) // 2. Copy this file to your server's oxide/plugins/ directory // 3. Configure via oxide/config/SentinelID.json (auto-generated on first load) // // Commands: // /sid — Check your own SentinelID auth status // /sid check — (Admin) Check another player's status // /sid list — (Admin) List all verified players // /sid ban — (Admin) Ban a player's device // /sid unban — (Admin) Revoke a ban // // Permissions: // sentinelid.admin — Required for admin commands // sentinelid.bypass — Skip verification (for admins/mods) using System; using System.Collections.Generic; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; namespace Oxide.Plugins { [Info("SentinelID", "C-Squared Solutions", "2.0.0")] [Description("SentinelID hardware device verification and whitelist enforcement")] public class SentinelID : RustPlugin { #region Configuration private PluginConfig config; private class PluginConfig { [JsonProperty("SidecarUrl")] public string SidecarUrl { get; set; } = "http://127.0.0.1:7999"; [JsonProperty("GracePeriodSeconds")] public float GracePeriodSeconds { get; set; } = 30f; [JsonProperty("CheckIntervalSeconds")] public float CheckIntervalSeconds { get; set; } = 5f; [JsonProperty("KickUnverified")] public bool KickUnverified { get; set; } = true; [JsonProperty("MessagePrefix")] public string MessagePrefix { get; set; } = "[SentinelID]"; [JsonProperty("Messages")] public MessageConfig Messages { get; set; } = new MessageConfig(); } private class MessageConfig { [JsonProperty("AuthSuccess")] public string AuthSuccess { get; set; } = "SentinelID verified. Welcome!"; [JsonProperty("AuthRequired")] public string AuthRequired { get; set; } = "This server requires SentinelID verification. Run the companion app to authenticate, then rejoin."; [JsonProperty("KickReason")] public string KickReason { get; set; } = "SentinelID verification required. Run the companion app and rejoin."; [JsonProperty("GracePeriodWarning")] public string GracePeriodWarning { get; set; } = "You have {seconds}s to authenticate with SentinelID or you will be kicked."; [JsonProperty("StatusVerified")] public string StatusVerified { get; set; } = "You are SentinelID verified (device: {device_id})."; [JsonProperty("StatusNotVerified")] public string StatusNotVerified { get; set; } = "You are NOT SentinelID verified."; [JsonProperty("BanSuccess")] public string BanSuccess { get; set; } = "Ban created (ID: {ban_id}) for {player}."; [JsonProperty("BanFailed")] public string BanFailed { get; set; } = "Ban failed: {error}"; [JsonProperty("UnbanSuccess")] public string UnbanSuccess { get; set; } = "Ban {ban_id} revoked."; [JsonProperty("UnbanFailed")] public string UnbanFailed { get; set; } = "Unban failed: {error}"; } protected override void LoadDefaultConfig() { config = new PluginConfig(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) { LoadDefaultConfig(); } } catch { PrintWarning("Config file is corrupt or invalid, loading defaults..."); LoadDefaultConfig(); } } protected override void SaveConfig() { Config.WriteObject(config, true); } #endregion #region State // Cached auth results (steamId -> device_id) private Dictionary verifiedPlayers = new Dictionary(); // Players in grace period (steamId -> join time) private Dictionary pendingPlayers = new Dictionary(); // Failed login attempts (steamId -> count) — persisted to data file private Dictionary failedAttempts = new Dictionary(); private const int MaxFailedAttempts = 3; // Timer for checking pending players private Timer checkTimer; private const string PermAdmin = "sentinelid.admin"; private const string PermBypass = "sentinelid.bypass"; #endregion #region Oxide Hooks private void Init() { permission.RegisterPermission(PermAdmin, this); permission.RegisterPermission(PermBypass, this); LoadFailedAttempts(); } private void OnServerInitialized() { checkTimer = timer.Every(config.CheckIntervalSeconds, CheckPendingPlayers); } private void Unload() { checkTimer?.Destroy(); SaveFailedAttempts(); verifiedPlayers.Clear(); pendingPlayers.Clear(); } private void LoadFailedAttempts() { failedAttempts = Interface.Oxide.DataFileSystem.ReadObject>("SentinelID_FailedAttempts") ?? new Dictionary(); } private void SaveFailedAttempts() { Interface.Oxide.DataFileSystem.WriteObject("SentinelID_FailedAttempts", failedAttempts); } private object CanClientLogin(Network.Connection connection) { // Allow admins/mods to bypass string steamId = connection.userid.ToString(); if (permission.UserHasPermission(steamId, PermBypass)) return null; // Allow // Check if already verified with sidecar // Can't do async here, so we allow connection and check in OnPlayerConnected return null; } private void OnPlayerConnected(BasePlayer player) { if (player == null) return; string steamId = player.UserIDString; // Skip verification for players with bypass permission if (permission.UserHasPermission(steamId, PermBypass)) { verifiedPlayers[steamId] = "bypass"; Puts($"Bypass: {steamId} has sentinelid.bypass permission"); return; } // Check if already banned from too many failed attempts int attempts = 0; failedAttempts.TryGetValue(steamId, out attempts); if (attempts >= MaxFailedAttempts) { Puts($"Blocked: {steamId} has {attempts} failed attempts — server banned"); player.Kick("You have been banned from this server for repeatedly joining without SentinelID verification."); return; } // Query sidecar for auth status CheckPlayerAuth(steamId, (authorized, deviceId) => { if (player == null || !player.IsConnected) return; if (authorized) { verifiedPlayers[steamId] = deviceId ?? "unknown"; pendingPlayers.Remove(steamId); // Clear failed attempts on successful auth if (failedAttempts.ContainsKey(steamId)) { failedAttempts.Remove(steamId); SaveFailedAttempts(); } SendPrefixed(player, config.Messages.AuthSuccess); Puts($"Auth OK: {steamId} (device: {deviceId})"); } else { // Start grace period pendingPlayers[steamId] = UnityEngine.Time.realtimeSinceStartup; string warning = config.Messages.GracePeriodWarning .Replace("{seconds}", config.GracePeriodSeconds.ToString("F0")); SendPrefixed(player, warning); SendPrefixed(player, config.Messages.AuthRequired); int remaining = MaxFailedAttempts - attempts - 1; if (remaining <= 1) { SendPrefixed(player, $"Warning: {remaining} attempt(s) remaining before you are banned from this server."); } Puts($"No auth for {steamId} — {config.GracePeriodSeconds}s grace period started (attempt {attempts + 1}/{MaxFailedAttempts})"); } }); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; string steamId = player.UserIDString; pendingPlayers.Remove(steamId); verifiedPlayers.Remove(steamId); } #endregion #region Grace Period Enforcement private void CheckPendingPlayers() { if (pendingPlayers.Count == 0) return; float now = UnityEngine.Time.realtimeSinceStartup; var toCheck = new List(); foreach (var kvp in pendingPlayers) { float elapsed = now - kvp.Value; if (elapsed >= config.GracePeriodSeconds) { toCheck.Add(kvp.Key); } } foreach (string steamId in toCheck) { pendingPlayers.Remove(steamId); // Final check before kicking CheckPlayerAuth(steamId, (authorized, deviceId) => { if (authorized) { verifiedPlayers[steamId] = deviceId ?? "unknown"; BasePlayer player = BasePlayer.Find(steamId); if (player != null && player.IsConnected) { SendPrefixed(player, config.Messages.AuthSuccess); } Puts($"Grace period: {steamId} verified before deadline"); return; } // Increment failed attempts int count = 0; failedAttempts.TryGetValue(steamId, out count); count++; failedAttempts[steamId] = count; SaveFailedAttempts(); if (!config.KickUnverified) { PrintWarning($"Grace expired for {steamId} — would kick but KickUnverified is false (attempt {count}/{MaxFailedAttempts})"); return; } BasePlayer target = BasePlayer.Find(steamId); if (target != null && target.IsConnected) { if (count >= MaxFailedAttempts) { PrintWarning($"Banning {steamId} from server — {count} failed verification attempts"); target.Kick("You have been banned from this server for repeatedly joining without SentinelID verification."); } else { PrintWarning($"Kicking unverified player: {steamId} (attempt {count}/{MaxFailedAttempts})"); target.Kick(config.Messages.KickReason); } } }); } } #endregion #region Chat Commands [ChatCommand("sid")] private void CmdSentinelId(BasePlayer player, string command, string[] args) { if (player == null) return; if (args.Length == 0 || args[0] == "status") { if (verifiedPlayers.TryGetValue(player.UserIDString, out string deviceId)) { SendPrefixed(player, config.Messages.StatusVerified.Replace("{device_id}", deviceId)); } else { SendPrefixed(player, config.Messages.StatusNotVerified); } return; } // Admin commands if (!permission.UserHasPermission(player.UserIDString, PermAdmin)) { SendPrefixed(player, "You do not have permission to use admin commands."); return; } switch (args[0].ToLower()) { case "check": if (args.Length < 2) { SendPrefixed(player, "Usage: /sid check "); return; } CmdCheckPlayer(player, args[1]); break; case "list": CmdListVerified(player); break; case "ban": if (args.Length < 4) { SendPrefixed(player, "Usage: /sid ban "); return; } CmdBanPlayer(player, args[1], args[2], string.Join(" ", args, 3, args.Length - 3)); break; case "unban": if (args.Length < 2) { SendPrefixed(player, "Usage: /sid unban "); return; } CmdUnbanPlayer(player, args[1]); break; case "pardon": if (args.Length < 2) { SendPrefixed(player, "Usage: /sid pardon — clears failed login attempts"); return; } CmdPardon(player, args[1]); break; default: SendPrefixed(player, "Commands: /sid, /sid check, /sid list, /sid ban, /sid unban, /sid pardon"); break; } } private void CmdCheckPlayer(BasePlayer admin, string target) { BasePlayer targetPlayer = BasePlayer.Find(target); if (targetPlayer == null) { SendPrefixed(admin, $"Player '{target}' not found."); return; } string steamId = targetPlayer.UserIDString; string displayName = targetPlayer.displayName; if (verifiedPlayers.TryGetValue(steamId, out string deviceId)) { SendPrefixed(admin, $"{displayName} ({steamId}) is SentinelID verified (device: {deviceId})"); } else if (pendingPlayers.ContainsKey(steamId)) { float elapsed = UnityEngine.Time.realtimeSinceStartup - pendingPlayers[steamId]; float remaining = config.GracePeriodSeconds - elapsed; SendPrefixed(admin, $"{displayName} ({steamId}) is pending verification ({remaining:F0}s remaining)"); } else { CheckPlayerAuth(steamId, (authorized, devId) => { if (authorized) { verifiedPlayers[steamId] = devId ?? "unknown"; SendPrefixed(admin, $"{displayName} ({steamId}) is SentinelID verified (device: {devId})"); } else { SendPrefixed(admin, $"{displayName} ({steamId}) is NOT SentinelID verified"); } }); } } private void CmdListVerified(BasePlayer admin) { if (verifiedPlayers.Count == 0) { SendPrefixed(admin, "No verified players online."); return; } SendPrefixed(admin, $"Verified players ({verifiedPlayers.Count}):"); foreach (var kvp in verifiedPlayers) { BasePlayer p = BasePlayer.Find(kvp.Key); string name = p != null ? p.displayName : kvp.Key; admin.ChatMessage($" {name} — device: {kvp.Value}"); } if (pendingPlayers.Count > 0) { admin.ChatMessage($"\nPending verification: {pendingPlayers.Count} player(s)"); } } private void CmdPardon(BasePlayer admin, string steamId) { if (failedAttempts.ContainsKey(steamId)) { failedAttempts.Remove(steamId); SaveFailedAttempts(); SendPrefixed(admin, $"Cleared failed attempts for {steamId}. They can rejoin."); } else { SendPrefixed(admin, $"{steamId} has no failed attempts on record."); } } private void CmdBanPlayer(BasePlayer admin, string target, string banType, string reason) { if (banType != "cheat" && banType != "social") { SendPrefixed(admin, "Ban type must be 'cheat' or 'social'."); return; } BasePlayer targetPlayer = BasePlayer.Find(target); if (targetPlayer == null) { SendPrefixed(admin, $"Player '{target}' not found."); return; } string steamId = targetPlayer.UserIDString; string displayName = targetPlayer.displayName; string deviceId = null; verifiedPlayers.TryGetValue(steamId, out deviceId); if (deviceId != null && deviceId != "bypass") { PostBan(admin, steamId, displayName, deviceId, banType, reason); } else { CheckPlayerAuth(steamId, (authorized, devId) => { if (devId == null || devId == "") { SendPrefixed(admin, $"Cannot ban {displayName}: no verified device on record."); return; } PostBan(admin, steamId, displayName, devId, banType, reason); }); } } private void PostBan(BasePlayer admin, string steamId, string displayName, string deviceId, string banType, string reason) { string url = $"{config.SidecarUrl.TrimEnd('/')}/ban"; string body = JsonConvert.SerializeObject(new Dictionary { {"steam_id", steamId}, {"device_id", deviceId}, {"ban_type", banType}, {"scope", "game"}, {"reason_code", reason} }); webrequest.Enqueue(url, body, (code, response) => { if (code == 200 && !string.IsNullOrEmpty(response)) { try { var result = JsonConvert.DeserializeObject>(response); object banId; result.TryGetValue("ban_id", out banId); string msg = config.Messages.BanSuccess .Replace("{ban_id}", banId?.ToString() ?? "?") .Replace("{player}", displayName); SendPrefixed(admin, msg); verifiedPlayers.Remove(steamId); // Kick the banned player BasePlayer banned = BasePlayer.Find(steamId); if (banned != null && banned.IsConnected) { banned.Kick("SentinelID: You have been banned."); } } catch (Exception ex) { SendPrefixed(admin, config.Messages.BanFailed.Replace("{error}", ex.Message)); } } else { string error = $"HTTP {code}"; if (!string.IsNullOrEmpty(response)) { try { var err = JsonConvert.DeserializeObject>(response); object errMsg; if (err.TryGetValue("error", out errMsg)) error = errMsg.ToString(); } catch { } } SendPrefixed(admin, config.Messages.BanFailed.Replace("{error}", error)); } }, this, RequestMethod.POST, new Dictionary { {"Content-Type", "application/json"} }); } private void CmdUnbanPlayer(BasePlayer admin, string banIdStr) { string url = $"{config.SidecarUrl.TrimEnd('/')}/unban"; string body = JsonConvert.SerializeObject(new Dictionary { {"ban_id", banIdStr} }); webrequest.Enqueue(url, body, (code, response) => { if (code == 200) { SendPrefixed(admin, config.Messages.UnbanSuccess.Replace("{ban_id}", banIdStr)); } else { string error = $"HTTP {code}"; if (!string.IsNullOrEmpty(response)) { try { var err = JsonConvert.DeserializeObject>(response); object errMsg; if (err.TryGetValue("error", out errMsg)) error = errMsg.ToString(); } catch { } } SendPrefixed(admin, config.Messages.UnbanFailed.Replace("{error}", error)); } }, this, RequestMethod.POST, new Dictionary { {"Content-Type", "application/json"} }); } #endregion #region Console Commands [ConsoleCommand("sentinelid.status")] private void CcmdStatus(ConsoleSystem.Arg arg) { string msg = $"SentinelID Status:\n" + $" Verified players: {verifiedPlayers.Count}\n" + $" Pending players: {pendingPlayers.Count}\n" + $" Sidecar URL: {config.SidecarUrl}\n" + $" Grace period: {config.GracePeriodSeconds}s\n" + $" Kick unverified: {config.KickUnverified}"; if (verifiedPlayers.Count > 0) { msg += "\n\n Verified:"; foreach (var kvp in verifiedPlayers) { BasePlayer p = BasePlayer.Find(kvp.Key); string name = p != null ? p.displayName : kvp.Key; msg += $"\n {name} ({kvp.Key}) — device: {kvp.Value}"; } } arg.ReplyWith(msg); } #endregion #region Sidecar HTTP Communication private void CheckPlayerAuth(string steamId, Action callback) { string url = $"{config.SidecarUrl.TrimEnd('/')}/check_player/{steamId}"; webrequest.Enqueue(url, null, (code, response) => { if (code != 200 || string.IsNullOrEmpty(response)) { if (code == 0) { PrintWarning($"Cannot reach sidecar at {config.SidecarUrl} — is it running?"); } else if (code != 200) { PrintWarning($"Sidecar returned HTTP {code} for {steamId}"); } callback(false, null); return; } try { var result = JsonConvert.DeserializeObject(response); callback(result.Authorized, result.DeviceId); } catch (Exception ex) { PrintError($"Failed to parse sidecar response: {ex.Message}"); callback(false, null); } }, this, RequestMethod.GET); } private class CheckPlayerResponse { [JsonProperty("steam_id")] public string SteamId { get; set; } [JsonProperty("authorized")] public bool Authorized { get; set; } [JsonProperty("device_id")] public string DeviceId { get; set; } [JsonProperty("expires_at")] public double? ExpiresAt { get; set; } } #endregion #region Helpers private void SendPrefixed(BasePlayer player, string message) { player.ChatMessage($"{config.MessagePrefix} {message}"); } #endregion } }