// NETSHIELD.DK independent implementation. Generated by bin/build-plugins.php. // Development release: validate against your exact Rust Dedicated Server + Oxide build. using System; using System.Collections.Generic; using System.Text.RegularExpressions; using Newtonsoft.Json; using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; namespace Oxide.Plugins { [Info("NetShield Ban List", "NETSHIELD.DK", "0.1.1")] [Description("Read-only approved ban checks for NETSHIELD.DK. No gameplay telemetry.")] public class NetShieldBanList : CovalencePlugin { [PluginReference] private Plugin NetShieldAC; private class PluginSettings : CoreSettings { } private bool ExternalOwnerAllows() { if (NetShieldAC==null || !NetShieldAC.IsLoaded) return true; object owner=NetShieldAC.Call("API_NetShieldOwnsBanChecks"); return !(owner is bool) || !(bool)owner; } private bool CanRecoverBanAuthentication() { return true; } private void StartProduct() { } private void StopProduct() { } private void OnAuthenticationDenied() { } private void ObserveSession(IPlayer player,string kind) { } [Command("netshield.status")] private void Status(IPlayer player,string command,string[] args) { if(player.IsServer || player.IsAdmin) player.Reply(CoreStatus()); } // Shared NETSHIELD ban transport. Generated into each standalone plugin. private PluginSettings settings; private bool coreEnabled, stopped, authDenied; private double authRetryAt, globalRetryAt, nextRefresh; private int generation, requests, failures, dropped, kicks; private readonly Dictionary cache = new Dictionary(); private readonly Queue checks = new Queue(); private readonly HashSet pending = new HashSet(); private readonly Dictionary flights = new Dictionary(); private readonly System.Random jitter = new System.Random(); private static readonly DateTime Epoch = new DateTime(1970,1,1,0,0,0,DateTimeKind.Utc); private class CoreSettings { public string ApiBaseUrl = "https://netshield.dk"; public string ServerId = ""; public string ApiKey = ""; public bool AllowInsecureLocalhost = false; public bool EnforceBans = true; public int MaxCacheEntries = 2048; public int MaxQueuedChecks = 512; public int MaxConcurrentChecks = 2; } private class CheckWork { public string Id; public int Attempt; public double Due; } private class Flight { public CheckWork Work; public double Started; public int Generation; public bool Done; } private class CacheEntry { public BanReply Reply; public double FreshUntil; } private class BanReply { [JsonProperty("schema_version", Required=Required.Always)] public int Schema; [JsonProperty("server_id", Required=Required.Always)] public string ServerId; [JsonProperty("steam_id", Required=Required.Always)] public string SteamId; [JsonProperty("checked_at", Required=Required.Always)] public long CheckedAt; [JsonProperty("valid_until", Required=Required.Always)] public long ValidUntil; [JsonProperty("blocked", Required=Required.Always)] public bool Blocked; [JsonProperty("ban", Required=Required.AllowNull)] public BanRecord Ban; } private class BanRecord { [JsonProperty("id", Required=Required.Always)] public string Id; [JsonProperty("scope", Required=Required.Always)] public string Scope; [JsonProperty("reason", Required=Required.Always)] public string Reason; [JsonProperty("expires_at", Required=Required.AllowNull)] public long? ExpiresAt; } private static long UnixNow() { return (long)(DateTime.UtcNow-Epoch).TotalSeconds; } private static double MonoNow() { return UnityEngine.Time.realtimeSinceStartup; } private static bool SteamId(string id) { ulong value; return id != null && id.Length==17 && ulong.TryParse(id,out value) && value>=76561197960265729UL && value<=76561202255233023UL; } protected override void LoadDefaultConfig() { settings = new PluginSettings(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { settings = Config.ReadObject(); } catch { settings=null; PrintError("Invalid configuration. Correct the JSON and reload. No enforcement is active."); } } protected override void SaveConfig() { if(settings!=null) Config.WriteObject(settings,true); } private void OnServerInitialized() { if (settings==null) return; Uri uri; bool validUrl = Uri.TryCreate(settings.ApiBaseUrl,UriKind.Absolute,out uri) && string.IsNullOrEmpty(uri.UserInfo) && string.IsNullOrEmpty(uri.Query) && string.IsNullOrEmpty(uri.Fragment) && uri.AbsolutePath=="/" && (uri.Scheme=="https" || (settings.AllowInsecureLocalhost && uri.Scheme=="http" && uri.IsLoopback)); if (!validUrl || !Regex.IsMatch(settings.ServerId??"","^[a-f0-9]{32}$") || !Regex.IsMatch(settings.ApiKey??"","^ns_[a-f0-9]{64}$")) { PrintWarning("Set ApiBaseUrl, ServerId and ApiKey in the plugin configuration, then reload. No credentials are logged."); return; } settings.ApiBaseUrl = settings.ApiBaseUrl.TrimEnd('/'); settings.MaxCacheEntries=Math.Max(64,Math.Min(8192,settings.MaxCacheEntries)); settings.MaxQueuedChecks=Math.Max(32,Math.Min(2048,settings.MaxQueuedChecks)); settings.MaxConcurrentChecks=Math.Max(1,Math.Min(4,settings.MaxConcurrentChecks)); coreEnabled=true; timer.Every(1f,PumpChecks); StartProduct(); RefreshPlayers(); } private bool OwnsBans() { return coreEnabled && !stopped && !authDenied && ExternalOwnerAllows(); } private object CanUserLogin(string name,string id,string ipAddress) { if (!OwnsBans() || !settings.EnforceBans) return null; CacheEntry entry; return TryCache(id,out entry) && entry.Reply.Blocked ? BanMessage(entry.Reply.Ban) : null; } private void OnUserConnected(IPlayer player) { if (player==null || !SteamId(player.Id)) return; QueueCheck(player.Id); ObserveSession(player,"connected"); } private void OnUserDisconnected(IPlayer player) { if(player!=null && SteamId(player.Id)) ObserveSession(player,"disconnected"); } private void RefreshPlayers() { nextRefresh=MonoNow()+60; if (!OwnsBans()) return; foreach (IPlayer player in players.Connected) if(player!=null && SteamId(player.Id)) QueueCheck(player.Id); } private void QueueCheck(string id) { if (!OwnsBans() || !SteamId(id)) return; CacheEntry entry; if (TryCache(id,out entry)) { Enforce(id,entry.Reply); return; } if (pending.Contains(id)) return; if (pending.Count>=settings.MaxQueuedChecks) { dropped++; return; } pending.Add(id); checks.Enqueue(new CheckWork { Id=id, Attempt=0, Due=MonoNow() }); } private void PumpChecks() { if (stopped || !coreEnabled) return; if (authDenied && CanRecoverBanAuthentication() && MonoNow()>=authRetryAt) authDenied=false; if (!OwnsBans()) { if (pending.Count>0 || cache.Count>0) ClearBanState(); return; } if (MonoNow()>=nextRefresh) { PruneCache(); RefreshPlayers(); } // Watchdog ensures every request releases its slot even if Oxide never invokes a callback. var overdue=new List(); foreach (Flight flight in flights.Values) if(MonoNow()-flight.Started>=15) overdue.Add(flight); foreach (Flight flight in overdue) FinishCheck(flight,0,null); if (MonoNow() 0 && checks.Count>0 && flights.CountMonoNow()) { checks.Enqueue(work); continue; } IPlayer player=players.FindPlayerById(work.Id); if (player==null || !player.IsConnected) { pending.Remove(work.Id); continue; } Flight flight=new Flight { Work=work,Started=MonoNow(),Generation=generation }; flights[work.Id]=flight; requests++; try { webrequest.Enqueue(settings.ApiBaseUrl+"/api/v1/public/bans/check/"+work.Id,null, (code,body)=>FinishCheck(flight,code,body),this,RequestMethod.GET,Headers(),10f); } catch { FinishCheck(flight,0,null); } } } private Dictionary Headers() { return new Dictionary { {"Authorization","Bearer "+settings.ApiKey}, {"Accept","application/json"}, {"Content-Type","application/json"} }; } private void FinishCheck(Flight flight,int code,string body) { if (flight.Done) return; flight.Done=true; if (stopped || flight.Generation!=generation) return; flights.Remove(flight.Work.Id); if (!OwnsBans()) { pending.Remove(flight.Work.Id); return; } if (code==401 || code==403) { DenyAuthentication(); return; } BanReply reply; if (code==200 && ParseReply(body,flight.Work.Id,out reply)) { pending.Remove(flight.Work.Id); StoreCache(reply); Enforce(flight.Work.Id,reply); return; } failures++; bool retry=code==0 || code==429 || code>=500 || code==200; if (code==429) globalRetryAt=MonoNow()+60; if (retry && flight.Work.Attempt<3) { flight.Work.Attempt++; flight.Work.Due=Math.Max(globalRetryAt,MonoNow()+Math.Pow(2,flight.Work.Attempt)+jitter.NextDouble()); checks.Enqueue(flight.Work); } else pending.Remove(flight.Work.Id); } private bool ParseReply(string body,string id,out BanReply reply) { reply=null; if (string.IsNullOrEmpty(body) || body.Length>8192) return false; try { reply=JsonConvert.DeserializeObject(body); long now=UnixNow(); if(reply==null || reply.Schema!=1 || reply.ServerId!=settings.ServerId || reply.SteamId!=id || reply.CheckedAtnow+30 || reply.ValidUntil<=now || reply.ValidUntil<=reply.CheckedAt || reply.ValidUntil>reply.CheckedAt+60) return false; if(!reply.Blocked) return reply.Ban==null; BanRecord ban=reply.Ban; return ban!=null && Regex.IsMatch(ban.Id??"","^[a-f0-9]{32}$") && (ban.Scope=="global" || ban.Scope=="local") && !string.IsNullOrWhiteSpace(ban.Reason) && ban.Reason.Length<=200 && (!ban.ExpiresAt.HasValue || (ban.ExpiresAt.Value>now && reply.ValidUntil<=ban.ExpiresAt.Value)); } catch { return false; } } private bool TryCache(string id,out CacheEntry entry) { if (cache.TryGetValue(id,out entry)) { if(entry.FreshUntil>MonoNow() && entry.Reply.ValidUntil>UnixNow()) return true; cache.Remove(id); } entry=null; return false; } private void StoreCache(BanReply reply) { if (cache.Count>=settings.MaxCacheEntries && !cache.ContainsKey(reply.SteamId)) { PruneCache(); if (cache.Count>=settings.MaxCacheEntries) { string remove=null; foreach(string key in cache.Keys) { remove=key; break; } if(remove!=null) cache.Remove(remove); } } cache[reply.SteamId]=new CacheEntry { Reply=reply,FreshUntil=MonoNow()+Math.Min(60,reply.ValidUntil-UnixNow()) }; } private void PruneCache() { var expired=new List(); foreach(var pair in cache) if(pair.Value.FreshUntil<=MonoNow() || pair.Value.Reply.ValidUntil<=UnixNow()) expired.Add(pair.Key); foreach(string id in expired) cache.Remove(id); } private string BanMessage(BanRecord ban) { string reason=Regex.Replace(ban.Reason,"<[^>]*>|[\\r\\n\\t]"," "); return "NETSHIELD: "+reason+" | Reference: "+ban.Id+" | Appeal: "+settings.ApiBaseUrl+"/appeals"; } private void Enforce(string id,BanReply reply) { if (!OwnsBans() || !settings.EnforceBans || !reply.Blocked || reply.ValidUntil<=UnixNow()) return; IPlayer player=players.FindPlayerById(id); if (player!=null && player.IsConnected) { kicks++; player.Kick(BanMessage(reply.Ban)); } // Never write native permanent bans; revocation cannot leave an orphan Rust ban. } private void ClearBanState() { generation++; cache.Clear(); checks.Clear(); pending.Clear(); flights.Clear(); } private void DenyAuthentication() { authDenied=true; authRetryAt=MonoNow()+300; ClearBanState(); OnAuthenticationDenied(); PrintWarning("NETSHIELD access denied. Check approval or rotate the API key. Rust continues normally."); } private void Unload() { stopped=true; ClearBanState(); StopProduct(); } private string CoreStatus() { return "NETSHIELD configured="+coreEnabled+" ownsBans="+OwnsBans()+" cache="+cache.Count+ " queued="+checks.Count+" inFlight="+flights.Count+" requests="+requests+" failures="+failures+" dropped="+dropped+" kicks="+kicks; } } }