using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Net.Sockets; using System.Text; using System.Threading; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Plugins; namespace Oxide.Plugins { /// /// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks /// newline-delimited JSON over it: events out, commands in. /// /// /// Threading contract, which everything later in this plugin depends on: /// /// /// /// is called from the server's main thread. It formats nothing, /// blocks on nothing, and touches no socket. It enqueues and returns. A slow, wedged, /// or absent sidecar cannot stall the game. /// /// /// One link thread owns the socket. It connects, drains the queue, and reconnects with /// backoff. A single writer keeps event ordering intact. /// /// /// A reader thread parses inbound lines and hands each to the main thread via /// Interface.Oxide.NextTick. The reader touches no Unity object, no BasePlayer /// and no ConVar — every one of those is main-thread-only, and reading them from here /// is the kind of bug that presents as a crash somewhere else entirely. /// /// /// /// /// The outbound queue is bounded. On overflow the oldest record is dropped and counted, /// because telemetry is worth less than the server's memory. /// /// /// /// Loopback is the trust boundary. There is no token on this link: the plugin and /// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing Host at /// anything routable puts an unauthenticated command channel on the network. /// /// [Info("Runic Gateway", "RunicGateway", "0.1.0")] [Description("Bridges this Rust server to a Runic Gateway website through a rust-link sidecar.")] internal class RunicGateway : RustPlugin { /// /// Wire-protocol version. Declared in three places that must agree: here, /// PROTOCOL_VERSION in the sidecar, and module.json in Module-Rust. The /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// private const int ProtocolVersion = 5; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost /// a few seconds of buffering, not half a minute. /// private const int MaxBackoffMs = 5000; /// The longest line accepted from the sidecar. Over-long lines are discarded. private const int MaxInboundLineBytes = 1024 * 1024; /// /// How long to wait for the TCP connect itself before giving up and backing off. /// /// /// It exists because TcpClient.Connect is blocking and has no timeout of its /// own, and the OS default is tens of seconds. A host that REFUSES answers instantly — /// which is every loopback test, and why this was invisible for two phases — but a host /// that drops does not answer at all: a firewall, a wrong address, a machine /// that is off. The link thread then sits in the connect, deaf to everything, and /// Unload waits out its whole join on the game's MAIN THREAD. /// /// /// /// Generous against any real network and short against a hang. Loopback, which is the /// supported deployment, connects in well under a millisecond. /// /// private const int ConnectTimeoutMs = 3000; /// /// How often the boards are re-sent (PROTOCOL.md §8.3) and the per-player tallies are /// flushed (§8.6). One cadence serves both because both answer the same question — /// "what has this server been doing lately" — and two timers would only be two things /// to get out of step. /// private const float BoardCadenceSeconds = 60f; /// /// The side of one map grid cell, in world units. Rust's own grid, as every community /// map and every server console uses it. /// private const float GridCellSize = 146.3f; private static readonly ConcurrentQueue Outbound = new ConcurrentQueue(); private static readonly AutoResetEvent Wake = new AutoResetEvent(false); /// /// Set once, by Unload, and never reset. It is what lets a thread blocked on a /// CONNECT give up immediately instead of at the end of its timeout. /// /// /// It cannot be : that handle also means "the queue has something in /// it", which Emit signals constantly, so waiting on it during a connect would /// abandon the attempt every time a hook fired. Two meanings, two handles. /// /// private readonly ManualResetEvent _stopping = new ManualResetEvent(false); private PluginConfig _config; private Thread _link; private volatile bool _running; private volatile bool _connected; /// Set when the peer goes away, so the writer stops trying. private volatile bool _dead; /// /// Whether the "cannot reach the sidecar" line has been printed for this load. It is /// printed once, not per attempt: the retry runs every few seconds for ever. /// private volatile bool _loggedConnectFailure; /// /// Incremented per connection attempt. A reader from a previous connection must not be /// able to mark a newer one dead — reader.Join can time out, and the stale /// thread's finally block would otherwise tear down the connection that replaced it. /// private int _epoch; private int _depth; private long _sent, _dropped, _received, _connects, _writeErrors; /// /// Identifies this run of the server process. It is how the website tells a game /// restart apart from a sidecar reconnect — the same link comes back either way, and /// only one of the two means the world it is describing started over. /// /// /// It must therefore be stable across a plugin reload, which a fresh /// Guid at Init is not: oxide.reload would announce a brand new /// boot to a website that is about to reconcile its whole ledger on the strength of it, /// while the world it describes never moved. The process's own start time is the only /// value here that changes exactly when the thing it names changes. /// /// private string _bootId; /// /// The current wipe, as w-yyyyMMddTHHmmssZ, derived from the save's creation /// time. Null until the server has saved at least once — a server with no save has no /// wipe, and PROTOCOL.md §8.2 says that is reported by the field being **absent** /// rather than by an empty string that will sort beside every other empty string for /// ever. /// /// /// Cached rather than read per frame, and re-read on OnNewSave, which is the /// only moment it can change. A wipe is a fact about the world; nothing about a /// reconnect, a plugin reload or a restart moves it. /// /// private string _wipeId; /// /// Per-player counters waiting to be flushed as a player.tally frame. Main /// thread only — every writer is a game hook and the flush runs on a timer, which is /// also the main thread, so this needs no lock and must never acquire one. /// private readonly Dictionary _tallies = new Dictionary(); /// /// When each connected player connected, in epoch milliseconds, so a disconnect can /// report a session length. Main thread only. /// /// /// A player already on the server when this plugin loaded has no entry, and /// sessionSec is then absent from their disconnect rather than zero: an /// unknown session is not a zero-length one, and summing the zeros would under-report /// playtime quietly and for ever. /// /// private readonly Dictionary _connectedAt = new Dictionary(); /// /// Every hook name this plugin declares, and whether it has fired at least once. /// /// /// Both frameworks bind hooks by name and arity, through reflection, with no /// compile-time check: a hook Facepunch renames on a Thursday simply stops being /// called, and a hook one framework's catalogue does not list may or may not exist. /// Neither failure logs anything anywhere. rg.hooks reads this dictionary, which /// makes the plugin its own answer to both questions on whichever framework it is /// actually running under — and a cheaper answer than either catalogue. /// /// private readonly Dictionary _hookCounts = new Dictionary(); /// /// The repeating boards/tally timer. /// /// /// Typed as the plugin-facing Oxide.Plugins.Timer, which is what /// timer.Every hands back — not Oxide.Core.Libraries.Timer.TimerInstance, /// which is the library's own type and does not convert. Both frameworks would destroy /// this timer for us when the plugin unloads; holding the handle is what lets /// Unload stop it before the link thread is joined rather than after. /// /// private Timer _cadence; // ---- lifecycle ---- /// /// Every hook this plugin declares. Seeded into _hookCounts at zero so that /// rg.hooks can report a hook that has never fired — which is the whole /// question, and which a dictionary that only learns names as they arrive can never /// answer. /// private static readonly string[] ExpectedHooks = { "OnServerInitialized", "OnServerShutdown", "OnNewSave", "OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned", "OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath", "OnPlayerReported", "OnUserBanned", "OnUserUnbanned", "CanUserLogin", "OnUserApproved", "OnUserPermissionGranted", "OnUserPermissionRevoked", "OnUserGroupAdded", "OnUserGroupRemoved", "OnGroupPermissionGranted", "OnGroupPermissionRevoked", "OnPluginLoaded", "OnPluginUnloaded" }; private void Init() { foreach (string hook in ExpectedHooks) _hookCounts[hook] = 0L; _bootId = ResolveBootId(); _wipeId = ResolveWipeId(); _running = true; // Expired link codes are dropped on a timer, not on lookup: an unconfirmed code is // never looked up, so nothing else would ever remove one (protocol 3). timer.Every(60f, PurgeExpiredCodes); _link = new Thread(LinkLoop) { Name = "RunicGateway Link", IsBackground = true }; _link.Start(); } private void Unload() { // Attempted before `_running` goes false, because `Emit` refuses to enqueue once it // does — but be honest about what this achieves: the writer stops on the same flag a // few lines below and the queue is cleared after the join, so a frame enqueued here // only survives if the writer happens to be mid-drain. It usually is not. // // That is left as it is rather than fixed with a drain-before-exit, because draining // means waiting on a socket from `Unload`, which runs on the MAIN THREAD — the exact // stall phase 1 removed when a 1.9-second reload showed up as // `Calling 'Unload' took 1918ms`. A wedged peer would make it worse than the bug. // // What is actually lost is under a minute of one player's gathering, and only on a // manual plugin reload: a real shutdown flushes at `OnServerShutdown`, and a player // leaving flushes at their disconnect. try { FlushTallies(); } catch (Exception ex) { Puts($"could not flush tallies on unload: {ex.Message}"); } if (_cadence != null) { _cadence.Destroy(); _cadence = null; } // A half-drained permission sync is abandoned rather than finished, and the site is // told nothing — which is right: the report would describe a set that was only partly // applied, and the next sync re-derives the whole answer from the store anyway. What // must not happen is the timer outliving the plugin and writing during a reload. _permJob = null; StopPermTimer(); // A config write waiting on a reload dies here too, and its files stay as written. // That is the honest outcome: the rollback exists to undo an edit that took a plugin // down, and this plugin being unloaded is not evidence of either — it is usually THIS // plugin being reloaded by somebody. The caller times out and re-reads. StopConfigWatch(); _configJob = null; _running = false; _stopping.Set(); Wake.Set(); Thread t = _link; if (t != null && !t.Join(TimeSpan.FromSeconds(2.0))) Puts("link thread did not stop cleanly"); _link = null; _connected = false; _tallies.Clear(); // A reload leaves the static queue holding lines addressed to a connection that no // longer exists. Clearing it is the difference between a reload and a slow leak. string discard; while (Outbound.TryDequeue(out discard)) { } Interlocked.Exchange(ref _depth, 0); } // ---- configuration ---- private class PluginConfig { /// Where the sidecar listens. Loopback is the trust boundary; see the class docs. public string Host = "127.0.0.1"; public int Port = 7799; /// /// How many outbound lines may wait for a wedged sidecar before the oldest is /// dropped. Sized in lines rather than bytes because that is what is cheap to /// count; the inbound cap is what bounds a single line's size. /// public int QueueCap = 5000; /// /// This server's stable identity across wipes and restarts, as the website knows /// it. It is not derived from the hostname: an operator renames a server for /// a season and the site must not lose its history for it. /// public string ServerId = "main"; } protected override void LoadDefaultConfig() { Config.WriteObject(new PluginConfig(), true); } private void LoadConfigValues() { _config = Config.ReadObject() ?? new PluginConfig(); if (string.IsNullOrEmpty(_config.Host)) _config.Host = "127.0.0.1"; if (_config.Port <= 0 || _config.Port > 65535) _config.Port = 7799; // A cap of zero would drop every line on the way in and report a healthy link doing // nothing, which is the worst failure available here. if (_config.QueueCap < 16) _config.QueueCap = 16; if (string.IsNullOrEmpty(_config.ServerId)) _config.ServerId = "main"; } private void Loaded() { LoadConfigValues(); Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}"); } // ---- the outbound queue ---- /// /// Main thread. Non-blocking. must already be a complete JSON /// object with no embedded newline; the newline is appended by the writer as the frame /// delimiter. /// private void Emit(string line) { if (line == null || !_running) return; int cap = _config != null ? _config.QueueCap : 5000; // Drop-oldest. Bound first, then enqueue, so the queue can transiently sit one over the // cap but never grows without limit. while (Volatile.Read(ref _depth) >= cap) { string discard; if (!Outbound.TryDequeue(out discard)) break; Interlocked.Decrement(ref _depth); Interlocked.Increment(ref _dropped); } Outbound.Enqueue(line); Interlocked.Increment(ref _depth); Wake.Set(); } private void EmitFrame(Dictionary frame) { try { Emit(JsonConvert.SerializeObject(frame, Formatting.None)); } catch (Exception ex) { // A frame that cannot be serialized must not take the caller down with it — the // caller is usually a game hook. Puts($"could not serialize frame: {ex.Message}"); } } // ---- the link ---- private void LinkLoop() { int backoffMs = 500; while (_running) { TcpClient client = null; Thread reader = null; int epoch = Interlocked.Increment(ref _epoch); try { string host = _config != null ? _config.Host : "127.0.0.1"; int port = _config != null ? _config.Port : 7799; client = new TcpClient(); client.NoDelay = true; Connect(client, host, port); NetworkStream stream = client.GetStream(); stream.WriteTimeout = 5000; // a wedged peer must surface as an error, not a hang _dead = false; _connected = true; _loggedConnectFailure = false; // so a LATER outage is reported again backoffMs = 500; Interlocked.Increment(ref _connects); Puts($"connected to {host}:{port}"); // Building the boards reads the world, so it must happen on the main thread. // They are sent on every connect rather than once at startup: the sidecar // restarts independently of the game, and anything it needs up front has to be // re-sent per connection. Interface.Oxide.NextTick(SendBoards); NetworkStream localStream = stream; reader = new Thread(() => ReadLoop(localStream, epoch)) { Name = "RunicGateway Reader", IsBackground = true }; reader.Start(); WriteLoop(stream); } catch (Exception ex) { // Socket exception messages carry EMBEDDED newlines on Windows — the refusal // text and the endpoint arrive on separate lines — so trimming the ends is not // enough to keep the Oxide log one entry per line. string reason = Flatten(ex.Message); if (_connected) { Puts($"link error: {reason}"); } else if (!_loggedConnectFailure) { // The FIRST failure to connect, and only the first: a sidecar that is not // running yet is normal at boot and would otherwise spam the console every // few seconds, but a wrong Host or Port is otherwise silent for ever. _loggedConnectFailure = true; Puts($"cannot reach the sidecar: {reason} — retrying quietly until it answers"); } } finally { // Captured before it is cleared: a connection that ended because the READER saw // EOF leaves the writer to exit cleanly, so nothing throws and the catch above // never runs. Without this the link going down is invisible in the game console, // which is the one place an operator looks first. bool wasConnected = _connected; _connected = false; _dead = true; try { if (client != null) client.Close(); } catch { // Closing a socket that is already gone is not news. } if (reader != null) reader.Join(TimeSpan.FromSeconds(1.0)); if (wasConnected) Puts("sidecar link lost; reconnecting"); } if (!_running) break; // Nothing is listening yet, or the sidecar restarted. Both are normal. // // `Wake`, not `Thread.Sleep`: Unload runs on the MAIN thread and joins this one, so // an uninterruptible sleep here is a stall of up to MaxBackoffMs on every reload — // Oxide reports it as `Calling 'Unload' took 1918ms`, and the server is frozen for // that whole time. Unload sets `_running` false and signals `Wake`, so waiting on // the handle turns that stall into an immediate return. Wake.WaitOne(backoffMs); backoffMs = Math.Min(backoffMs * 2, MaxBackoffMs); } _connected = false; } /// /// Connects with a bound, and with a way out. /// /// /// The wait ends on whichever comes first: the connect completing, the plugin being /// unloaded, or . Without the middle one a reload against /// an unreachable sidecar costs the game's main thread the whole join timeout — /// observed as hook 'Unload' took longer than 100ms [2002ms] beside /// link thread did not stop cleanly, which is phase 1's stall arriving by a /// different road. /// /// private void Connect(TcpClient client, string host, int port) { IAsyncResult pending = client.BeginConnect(host, port, null, null); int signalled = WaitHandle.WaitAny( new WaitHandle[] { pending.AsyncWaitHandle, _stopping }, ConnectTimeoutMs); if (signalled != 0) { // Closing the client is what abandons the in-flight connect; the socket would // otherwise stay open behind an IAsyncResult nobody is going to end. try { client.Close(); } catch { } throw new System.IO.IOException( signalled == 1 ? "unloading" : $"connect to {host}:{port} timed out after {ConnectTimeoutMs}ms"); } // Throws the real socket error — a refusal, an unknown host — which is what the caller // logs. Only reached when the connect actually completed or failed on its own. client.EndConnect(pending); } private void WriteLoop(NetworkStream stream) { while (_running && !_dead) { string line; if (!Outbound.TryDequeue(out line)) { Wake.WaitOne(250); continue; } Interlocked.Decrement(ref _depth); try { byte[] bytes = Encoding.UTF8.GetBytes(line + "\n"); stream.Write(bytes, 0, bytes.Length); Interlocked.Increment(ref _sent); } catch (Exception) { // The record is already off the queue. Count it and let the outer loop // reconnect; re-queueing risks an unbounded retry storm against a dead peer. Interlocked.Increment(ref _writeErrors); _dead = true; throw; } } } private void ReadLoop(NetworkStream stream, int epoch) { byte[] buffer = new byte[8192]; StringBuilder line = new StringBuilder(512); bool discarding = false; try { while (_running && !_dead) { int read = stream.Read(buffer, 0, buffer.Length); if (read <= 0) break; // clean EOF: the sidecar closed. Normal. for (int i = 0; i < read; i++) { char c = (char)buffer[i]; if (c == '\n') { if (discarding) { // The tail of a line already given up on. Swallowing it here is // what stops the remainder being parsed as a line of its own. discarding = false; } else { Dispatch(line.ToString()); } line.Length = 0; } else if (c != '\r') { if (discarding) continue; line.Append(c); if (line.Length > MaxInboundLineBytes) { Puts("inbound line over the cap; discarding to the next newline"); line.Length = 0; discarding = true; } } } } } catch (System.IO.IOException) { // Expected when the peer vanishes mid-read. } catch (ObjectDisposedException) { // Expected when Unload closes the socket under us. } catch (Exception ex) { Puts($"reader error: {ex.Message}"); } finally { // Only tear down the connection this reader actually owned. if (Volatile.Read(ref _epoch) == epoch) { _dead = true; Wake.Set(); // let the writer notice and fall through to reconnect } } } /// /// Reader thread. Marshals to the main thread; nothing that touches the game happens /// here. /// private void Dispatch(string line) { if (string.IsNullOrEmpty(line)) return; Interlocked.Increment(ref _received); Interface.Oxide.NextTick(() => { try { HandleCommand(line); } catch (Exception ex) { // A malformed command must never escape into a game code path. Puts($"inbound handler threw: {ex}"); } }); } // ---- commands ---- /// Main thread. Every world read below is safe only because of that. private void HandleCommand(string line) { Dictionary cmd; try { cmd = JsonConvert.DeserializeObject>(line); } catch (Exception ex) { Puts($"unparseable command: {ex.Message}"); return; } if (cmd == null) return; object verbObj; if (!cmd.TryGetValue("cmd", out verbObj) || verbObj == null) return; string verb = verbObj.ToString(); object reqIdObj; string reqId = cmd.TryGetValue("reqId", out reqIdObj) && reqIdObj != null ? reqIdObj.ToString() : null; switch (verb) { case "ping": // `control`: neither kept nor forwarded. It exists to move the sidecar's // last-seen clock on a server nobody is playing on. EmitFrame(Frame("pong", "control")); break; case "server.status": Dictionary status = Frame("server.status", "reply"); Merge(status, ServerBody()); // The correlation id is echoed verbatim, and only when one was supplied. A // reply that invents one would be routed to nobody; a reply that omits one the // caller sent would leave that caller waiting out its whole timeout. if (reqId != null) status["reqId"] = reqId; EmitFrame(status); break; case "link.confirm": // The first command the WEBSITE originates (protocol 3). Everything before it // was the game talking, or the sidecar asking the game to say something it // already knew. HandleLinkConfirm(cmd, reqId); break; case "perm.catalogue": // Protocol 4. What this server's loaded plugins have registered — the option // source the website's authoring form offers, so a grant can only be written // against a name that will actually resolve. HandlePermCatalogue(reqId); break; case "perm.sync": // The first command that WRITES to the game (protocol 4, R2). Everything // before it read, or spent a code the game itself had minted. HandlePermSync(cmd, reqId); break; case "config.list": // Protocol 5 (R18). What settings files exist on this host, and which plugins // are loaded to reload them. A description of the tree, never its contents. HandleConfigList(reqId); break; case "config.read": HandleConfigRead(cmd, reqId); break; case "config.write": // The first command that writes to the game HOST's filesystem, which is the // most powerful thing the website can do to a server. HandleConfigWrite(cmd, reqId); break; default: Puts($"unknown command '{verb}'"); break; } } // ---- frames ---- /// /// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may /// emit a frame it did not get from here. /// /// /// is what the sidecar files on — event is appended to /// history, snapshot replaces a board, reply is routed by /// reqId, control is neither kept nor forwarded. It is the reason the /// sidecar needs no arm per kind and the reason the catalogue can grow for free. /// /// /// /// wipeId is omitted when the server has never saved. A server with no /// save has no wipe, and the difference between an absent field and an empty string is /// the difference between a fact and a row that sorts beside every other empty string /// for ever. /// /// private Dictionary Frame(string kind, string type) { var frame = new Dictionary { { "kind", kind }, { "type", type }, { "t", NowMs() }, { "serverId", _config != null ? _config.ServerId : "main" } }; // Resolved lazily while it is still unknown, which covers the case above on a // framework that never fires `OnServerInitialized` — and costs one static property // read per frame only until the answer exists. if (string.IsNullOrEmpty(_wipeId)) _wipeId = ResolveWipeId(); if (!string.IsNullOrEmpty(_wipeId)) frame["wipeId"] = _wipeId; return frame; } /// /// Main thread. Both boards, in the order a reader wants them: what the server is, /// then who is on it. /// /// /// Sent on every connect and every cadence tick. A board is current state with exactly /// one producer, re-sent rather than reconstructed — which is what makes "a restarted /// sidecar is fully populated within one connection" true by construction instead of /// by hoping nothing was in flight. /// /// private void SendBoards() { SendHello(); SendPlayersBoard(); } /// Main thread. private void SendHello() { Dictionary hello = Frame("server.hello", "snapshot"); Merge(hello, ServerBody()); hello["protocol"] = ProtocolVersion; hello["bootId"] = _bootId; hello["plugin"] = Version.ToString(); EmitFrame(hello); } /// /// Main thread. Who is connected right now, as the board the far side reconciles /// against — the connect and disconnect events carry the story in between. /// private void SendPlayersBoard() { var players = new List(); if (BasePlayer.activePlayerList != null) { foreach (BasePlayer player in BasePlayer.activePlayerList) { if (player == null) continue; var row = new Dictionary { { "steamId", player.UserIDString }, { "name", player.displayName }, { "sleeping", player.IsSleeping() } }; long since; if (_connectedAt.TryGetValue(player.userID, out since)) row["connectedAt"] = since; players.Add(row); } } Dictionary frame = Frame("players.online", "snapshot"); frame["count"] = players.Count; frame["players"] = players; EmitFrame(frame); } /// /// Main thread. The shared body of server.hello and server.status — the /// two frames differ in what wraps them, not in what they say about the server, and /// letting them drift is how a site ends up showing two different player counts. /// private Dictionary ServerBody() { var status = new Dictionary { { "hostname", ConVar.Server.hostname }, { "description", ConVar.Server.description }, { "level", ConVar.Server.level }, { "seed", ConVar.Server.seed }, { "worldSize", ConVar.Server.worldsize }, { "maxPlayers", ConVar.Server.maxplayers }, { "players", BasePlayer.activePlayerList != null ? BasePlayer.activePlayerList.Count : 0 }, { "joining", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Joining : 0 }, { "queued", ServerMgr.Instance != null ? ServerMgr.Instance.connectionQueue.Queued : 0 }, { "uptimeSec", (int)UnityEngine.Time.realtimeSinceStartup } }; // The instant `wipeId` is derived from, kept in its own right: the id is for grouping // rows and this is for showing a date, and deriving one back from the other is the // kind of parsing nobody should have to do twice. try { status["saveCreatedAt"] = SaveRestore.SaveCreatedTime.ToUniversalTime() .ToString("yyyy-MM-ddTHH:mm:ssZ", System.Globalization.CultureInfo.InvariantCulture); } catch (Exception) { // A server that has never saved has no created time. Absent, not zero. } return status; } /// /// Puts an IP address onto a frame, or leaves it off. /// /// /// The game does not always have one. A console banid against an offline id /// produces the literal string "0" rather than an address or a null — /// observed on a live server, not guessed — and a database column full of "0" is worse /// than one full of nulls, because "0" survives every is-it-missing test a reader /// writes and then fails whatever parses it. /// /// /// /// Absent, not zero. The same rule `saveCreatedAt` and `sessionSec` follow. /// /// private static void PutAddress(Dictionary frame, string ip) { if (string.IsNullOrEmpty(ip) || ip == "0" || ip == "0.0.0.0") return; frame["ip"] = ip; } private static void Merge(Dictionary into, Dictionary from) { foreach (KeyValuePair pair in from) into[pair.Key] = pair.Value; } /// /// Describes who or what killed somebody, onto . /// /// /// Four outcomes, and collapsing any two of them loses something a killfeed needs: /// player (a real kill, and the only one that belongs on a leaderboard), /// self, npc and environment — falling, drowning, a helicopter's /// fire, or the world simply deciding. `HitInfo` may legitimately be null, which is /// the environment case and not an error. /// /// private static void DescribeAttacker( Dictionary frame, BasePlayer victim, HitInfo info) { BaseEntity initiator = info != null ? info.Initiator : null; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; string weapon = null; if (info != null) { if (info.WeaponPrefab != null) weapon = info.WeaponPrefab.ShortPrefabName; else if (info.Weapon != null) weapon = info.Weapon.ShortPrefabName; } if (weapon != null) frame["weapon"] = weapon; if (initiator == null) { frame["attackerType"] = "environment"; return; } if (attacker != null && attacker.userID == victim.userID) { frame["attackerType"] = "self"; return; } if (attacker != null && !attacker.IsNpc) { frame["attackerType"] = "player"; frame["attackerId"] = attacker.UserIDString; frame["attackerName"] = attacker.displayName; } else { frame["attackerType"] = "npc"; frame["attackerName"] = initiator.ShortPrefabName; } float distance = info != null ? info.ProjectileDistance : 0f; if (distance <= 0f && initiator.transform != null && victim.transform != null) distance = UnityEngine.Vector3.Distance( initiator.transform.position, victim.transform.position); if (distance > 0f) frame["distance"] = (float)Math.Round(distance, 1); } // ---- the map grid ---- /// /// An entity's position as Rust's own map reference (H7), or null if it has no /// position to read. A reference, never a coordinate: a grid cell is 146.3 units /// across, which is precise enough to say where a fight happened and far too coarse to /// walk to somebody's door with. /// private static string GridOf(BaseEntity entity) { if (entity == null || entity.transform == null) return null; return Grid(entity.transform.position); } private static string Grid(UnityEngine.Vector3 position) { float size = ConVar.Server.worldsize; if (size <= 0f) return null; float half = size / 2f; int columns = (int)Math.Ceiling(size / GridCellSize); int x = (int)((position.x + half) / GridCellSize); // Rows are numbered from the NORTH edge downwards, which is why this is a subtraction // and not the same expression as the column. Getting it wrong mirrors every grid on // the map and is invisible without a second opinion. int z = (int)((half - position.z) / GridCellSize); x = Clamp(x, 0, columns - 1); z = Clamp(z, 0, columns - 1); return Column(x) + z.ToString(System.Globalization.CultureInfo.InvariantCulture); } private static int Clamp(int value, int low, int high) { if (high < low) return low; return value < low ? low : (value > high ? high : value); } /// A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA. private static string Column(int index) { string name = string.Empty; int n = index + 1; while (n > 0) { int remainder = (n - 1) % 26; name = (char)('A' + remainder) + name; n = (n - 1) / 26; } return name; } // ---- identity of the world ---- /// /// The current wipe, as w-yyyyMMddTHHmmssZ, or null if this server has never /// saved. /// /// /// Protocol 1 left this to the website. Protocol 2 derives it here because by now /// three components store rows that need it, and the plugin is the only one of the /// three that can read the value — the other two would be re-deriving something they /// were already told, and two derivations of one fact eventually disagree about a /// boundary. /// /// private static string ResolveWipeId() { try { DateTime created = SaveRestore.SaveCreatedTime.ToUniversalTime(); // A server that has never saved reports the bottom of the calendar. That is not a // wipe in 0001, it is the absence of one. if (created.Year < 2000) return null; return "w-" + created.ToString( "yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture); } catch (Exception) { return null; } } /// /// Records that a hook fired. See _hookCounts for why this exists at all. /// private void MarkHook(string name) { long count; _hookCounts.TryGetValue(name, out count); _hookCounts[name] = count + 1; } // ---- the read path ---- // // Every hook below obeys three rules, and each of them has a failure behind it. // // 1. **A read-path hook never vetoes.** Four of these are documented by uMod as // "returning a non-null value overrides default behavior" — a bridge that returned // something by accident would cancel a death, swallow a player's wood or refuse a // login, on somebody's production server at 3am. So they are declared `void`: both // frameworks take the method's return value, and a void method has none. The rule is // enforced by the signature rather than by remembering to write `return null`, which // is the only version of it that survives a year of edits. // // 2. **A hook that can fire more than once a second per player is a counter, not an // event.** `OnDispenserGather` fires on every swing at a tree. It accumulates into // `_tallies` and leaves as one `player.tally` frame a minute (PROTOCOL.md §8.6). // // 3. **Every hook marks itself.** `MarkHook` is what makes `rg.hooks` able to say which // of these actually fire on the framework this server is running — the standing // answer both to Facepunch renaming a hook and to a framework catalogue that does not // list one. // // All of it is the main thread, which is what makes reading `BasePlayer`, `ConVar` and a // transform legal here and illegal in the reader thread. private void OnServerInitialized(bool initial) { MarkHook("OnServerInitialized"); // Re-read the wipe, and this is not belt-and-braces: at a real server // start `Init` runs BEFORE the save is loaded, so `SaveCreatedTime` is not yet // meaningful and the id resolved there is null for the entire session. This hook is // the first moment the world is actually there. // // It was invisible on a development rig for the reason such things usually are: a // hot-reloaded plugin reads an already-loaded world and gets the right answer every // time. It took a server that BOOTED with the plugin installed — which is every real // one — to show `wipeId=none` on a server with a save sitting on disk. _wipeId = ResolveWipeId(); // Started here rather than in Init: this hook fires both at the end of server startup // and immediately on a hotload, so it is the one place that runs exactly once per // load with the world available either way. if (_cadence == null) _cadence = timer.Every(BoardCadenceSeconds, Cadence); Dictionary frame = Frame("server.initialized", "event"); frame["initial"] = initial; EmitFrame(frame); } private void OnServerShutdown() { MarkHook("OnServerShutdown"); FlushTallies(); EmitFrame(Frame("server.shutdown", "event")); } /// /// A new save is the only moment a wipe id can change, so it is the only place that /// re-reads it. The frame carries both ids because "which wipe did this replace" is /// the question a site asks when it draws the boundary, and it is unanswerable /// afterwards. /// private void OnNewSave(string filename) { MarkHook("OnNewSave"); string previous = _wipeId; _wipeId = ResolveWipeId(); // Built after the re-read, so `wipeId` on the envelope is the NEW wipe: this frame // belongs to the world it opens, not to the one it closes. Dictionary frame = Frame("server.wipe", "event"); if (!string.IsNullOrEmpty(previous)) frame["previousWipeId"] = previous; EmitFrame(frame); // Nothing from the old world is still true. The tallies are the only such state this // plugin holds; everything else lives on the far side of the link. _tallies.Clear(); _connectedAt.Clear(); Puts($"new save; wipe is now {(_wipeId ?? "unknown")}"); } private void OnPlayerConnected(BasePlayer player) { MarkHook("OnPlayerConnected"); if (player == null) return; _connectedAt[player.userID] = NowMs(); Dictionary frame = Frame("player.connected", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; EmitFrame(frame); } private void OnPlayerDisconnected(BasePlayer player, string reason) { MarkHook("OnPlayerDisconnected"); if (player == null) return; // Before the frame: a session's last minute of gathering is worth more than the order // of two lines, and after this point there is nothing to attribute it to. FlushTally(player.userID); Dictionary frame = Frame("player.disconnected", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; if (!string.IsNullOrEmpty(reason)) frame["reason"] = Flatten(reason); long since; if (_connectedAt.TryGetValue(player.userID, out since)) { frame["sessionSec"] = (int)((NowMs() - since) / 1000L); _connectedAt.Remove(player.userID); } // A player who was already on when this plugin loaded has no recorded connect, so // `sessionSec` is ABSENT rather than zero: a session of unknown length is not a // session of no length, and summing zeros silently under-reports playtime for ever. EmitFrame(frame); } private void OnPlayerRespawned(BasePlayer player) { MarkHook("OnPlayerRespawned"); if (player == null) return; Dictionary frame = Frame("player.respawned", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; EmitFrame(frame); } /// /// Void, and deliberately: uMod documents a non-null return here as overriding the /// death itself. See rule 1 at the top of this region. /// private void OnPlayerDeath(BasePlayer player, HitInfo info) { MarkHook("OnPlayerDeath"); if (player == null || player.IsNpc) return; Dictionary frame = Frame("player.death", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; frame["sleeping"] = player.IsSleeping(); string grid = GridOf(player); if (grid != null) frame["grid"] = grid; DescribeAttacker(frame, player, info); EmitFrame(frame); } /// /// Void, and deliberately: a non-null return here overrides chat delivery — the bridge /// would be silently eating messages. /// private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { MarkHook("OnPlayerChat"); if (player == null || string.IsNullOrEmpty(message)) return; Dictionary frame = Frame("player.chat", "event"); frame["steamId"] = player.UserIDString; frame["name"] = player.displayName; frame["channel"] = channel.ToString(); // Flattened for the same reason every other string on this wire is: one frame is one // line, and a chat message is the one field a player controls the bytes of. frame["message"] = Flatten(message); EmitFrame(frame); } /// /// Void, and deliberately: a non-null return here replaces what the player receives. /// This hook is a counter — see rule 2. /// private void OnDispenserGather(ResourceDispenser dispenser, BasePlayer player, Item item) { MarkHook("OnDispenserGather"); if (player == null || player.IsNpc || item == null || item.info == null) return; TallyFor(player).Gather(item.info.shortname, item.amount); } /// /// Everything that dies which is not a player death: NPC kills, which are a counter, /// and raided structures, which are an event with a `staff` class because a /// structure's grid is where somebody lives. /// /// /// The type checks are ordered cheapest-first and return early, because this hook /// fires for every tree, barrel and animal on the map. /// /// private void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { MarkHook("OnEntityDeath"); if (entity == null) return; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; bool byPlayer = attacker != null && !attacker.IsNpc; // Nothing below is interesting unless a real player did it. That single test discards // decay, despawns and the world killing itself, which is the bulk of this hook. if (!byPlayer) return; BasePlayer victimPlayer = entity as BasePlayer; if (victimPlayer != null) { // A real player's death is OnPlayerDeath's, not ours — emitting here too would // double every kill in the feed. Only the NPC kind is ours to count. if (victimPlayer.IsNpc) TallyFor(attacker).NpcKills++; return; } if (entity is BaseNpc) { TallyFor(attacker).NpcKills++; return; } BuildingBlock block = entity as BuildingBlock; if (block == null || block.OwnerID == 0UL) return; TallyFor(attacker).Structures++; Dictionary frame = Frame("entity.destroyed", "event"); frame["ownerId"] = block.OwnerID.ToString(); frame["prefab"] = block.ShortPrefabName; frame["attackerId"] = attacker.UserIDString; frame["attackerName"] = attacker.displayName; string grid = GridOf(block); if (grid != null) frame["grid"] = grid; EmitFrame(frame); } private void OnPlayerReported( BasePlayer reporter, string targetName, string targetId, string subject, string message, string type) { MarkHook("OnPlayerReported"); Dictionary frame = Frame("player.reported", "event"); if (reporter != null) { frame["reporterId"] = reporter.UserIDString; frame["reporterName"] = reporter.displayName; } frame["targetId"] = targetId; frame["targetName"] = targetName; frame["subject"] = Flatten(subject); frame["message"] = Flatten(message); frame["reportType"] = type; EmitFrame(frame); } private void OnUserBanned(string name, string id, string ipAddress, string reason) { MarkHook("OnUserBanned"); Dictionary frame = Frame("player.banned", "event"); frame["steamId"] = id; frame["name"] = name; frame["reason"] = Flatten(reason); PutAddress(frame, ipAddress); EmitFrame(frame); } private void OnUserUnbanned(string name, string id, string ipAddress) { MarkHook("OnUserUnbanned"); Dictionary frame = Frame("player.unbanned", "event"); frame["steamId"] = id; frame["name"] = name; PutAddress(frame, ipAddress); EmitFrame(frame); } /// /// Void, and this is the most important void in the file: a non-null return here /// **kicks the player**, with the returned string as the reason. The bridge is here to /// watch logins, never to decide one. /// /// /// It fires on every connection attempt, approved or not — so it is an attempt, not a /// denial. uMod publishes no OnUserRejected, and the only way to observe a /// refusal from this hook is to be the thing refusing. A denial is therefore the /// ABSENCE of a matching player.approved, decided by whoever reads the two /// frames later; PROTOCOL.md §8.8 records that as a correction to the trigger /// catalogue. /// /// private void CanUserLogin(string name, string id, string ipAddress) { MarkHook("CanUserLogin"); Dictionary frame = Frame("player.login.attempt", "event"); frame["steamId"] = id; frame["name"] = name; PutAddress(frame, ipAddress); EmitFrame(frame); } private void OnUserApproved(string name, string id, string ipAddress) { MarkHook("OnUserApproved"); Dictionary frame = Frame("player.approved", "event"); frame["steamId"] = id; frame["name"] = name; PutAddress(frame, ipAddress); EmitFrame(frame); } // ---- tallies ---- /// /// One player's counters between two flushes. Main thread only; no lock, ever. /// private class Tally { public readonly Dictionary Gathered = new Dictionary(); public int NpcKills; public int Structures; public string Name; public void Gather(string shortname, int amount) { if (string.IsNullOrEmpty(shortname) || amount <= 0) return; int have; Gathered.TryGetValue(shortname, out have); Gathered[shortname] = have + amount; } public bool IsEmpty() { return Gathered.Count == 0 && NpcKills == 0 && Structures == 0; } } private Tally TallyFor(BasePlayer player) { Tally tally; if (!_tallies.TryGetValue(player.userID, out tally)) { tally = new Tally(); _tallies[player.userID] = tally; } tally.Name = player.displayName; return tally; } /// /// Emits one player.tally per player with anything to report, and clears what it /// emitted. /// /// /// A tally frame is a delta, not a running total: it says what happened since /// the last flush, so the far side sums rather than diffs. A missed frame then costs /// one interval instead of corrupting the whole series, which is the failure mode a /// running total has over a link that is allowed to drop frames (and this one is — /// the outbound queue is drop-oldest by design). /// /// private void FlushTallies() { if (_tallies.Count == 0) return; // Materialised, because emitting mutates nothing here but clearing does, and iterating // a dictionary while clearing it is a different bug on every runtime. var ids = new List(_tallies.Keys); foreach (ulong id in ids) FlushTally(id); } private void FlushTally(ulong userId) { Tally tally; if (!_tallies.TryGetValue(userId, out tally)) return; _tallies.Remove(userId); if (tally.IsEmpty()) return; Dictionary frame = Frame("player.tally", "event"); frame["steamId"] = userId.ToString(); if (!string.IsNullOrEmpty(tally.Name)) frame["name"] = tally.Name; if (tally.Gathered.Count > 0) frame["gathered"] = tally.Gathered; if (tally.NpcKills > 0) frame["npcKills"] = tally.NpcKills; if (tally.Structures > 0) frame["structures"] = tally.Structures; EmitFrame(frame); } /// /// The one repeating job: re-send the boards and flush the tallies. /// /// /// It does nothing at all while the link is down. That is not an optimisation — the /// outbound queue is drop-oldest, so a disconnected server would spend its whole /// outage pushing minute-old boards over the top of the events that actually matter. /// A reconnect re-sends the boards anyway, which is the whole point of a board. /// /// private void Cadence() { if (!_connected) return; FlushTallies(); SendBoards(); } /// /// Collapses every run of whitespace and control characters to one space, so an /// exception message stays one log entry. /// /// /// The control half is not defensive padding. Mono's SocketException.Message on /// Windows comes back from a fixed-size OS buffer and is NUL-padded — a couple /// of hundred \0 bytes sitting in the middle of the sentence. \0 is not /// whitespace, so Trim does not touch it and neither does a whitespace-only /// collapse; the line looks like it contains a huge run of spaces and no amount of /// trimming removes it. /// /// private static string Flatten(string text) { if (string.IsNullOrEmpty(text)) return string.Empty; var sb = new StringBuilder(text.Length); bool lastWasSpace = false; foreach (char c in text) { if (char.IsWhiteSpace(c) || char.IsControl(c)) { if (!lastWasSpace && sb.Length > 0) sb.Append(' '); lastWasSpace = true; } else { sb.Append(c); lastWasSpace = false; } } return sb.ToString().TrimEnd(); } /// /// The server process's start instant, as boot-yyyyMMddTHHmmssZ. /// /// /// Process.StartTime is exact and identical on every read, which is what makes /// the id survive a plugin reload. The fallback derives the same instant from Unity's /// monotonic clock, and is only reached if the platform refuses the process query; it /// is rounded to the second deliberately, because the subtraction jitters in the last /// decimal and an id that changes on rounding is worse than one a second off. /// /// private static string ResolveBootId() { DateTime startUtc; try { startUtc = System.Diagnostics.Process.GetCurrentProcess().StartTime.ToUniversalTime(); } catch (Exception) { double up = UnityEngine.Time.realtimeSinceStartup; startUtc = DateTime.UtcNow.AddSeconds(-Math.Round(up)); } startUtc = new DateTime( startUtc.Year, startUtc.Month, startUtc.Day, startUtc.Hour, startUtc.Minute, startUtc.Second, DateTimeKind.Utc); return "boot-" + startUtc.ToString( "yyyyMMdd'T'HHmmss'Z'", System.Globalization.CultureInfo.InvariantCulture); } private static long NowMs() { return ToUnixMs(DateTime.UtcNow); } // ---- account linking ---- /// /// The alphabet a link code is drawn from. /// /// /// No O, 0, I or 1. A player reads this code off their screen and types it into /// a browser, often on a phone, sometimes reading it aloud to somebody else. Those four /// glyphs are the ones that get transcribed wrong, and a code that fails for a reason /// the player cannot see is worse than a longer one. /// /// private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; private const int CodeLength = 6; /// /// How long a code is good for. Short on purpose: R1 made this an authorization path, /// not a convenience — the site becomes the author of who may do what in game, so a /// code that lingers is a privilege waiting to be picked up. /// private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5); /// How long a player must wait between /link requests. private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30); /// /// An unconfirmed code, and the Steam identity it will bind. /// /// /// These live in memory and nowhere else, which is the same call the UO bridge /// made and it is the right one for the same reason: the secret exists only on the /// server that minted it, for five minutes, and nothing writes it to disk. A plugin /// reload drops every pending code — and phase 7b's config editor reloads plugins /// routinely — but the cost of that is a player typing /link again, which is /// cheaper than a credential in a second process. /// /// private sealed class PendingLink { public string SteamId; public string Name; public DateTime Expires; } /// code -> pending link. Main thread only, like every world read here. private readonly Dictionary _codes = new Dictionary(StringComparer.OrdinalIgnoreCase); /// steam id -> when they last asked, so /link cannot be spammed. private readonly Dictionary _lastLinkRequest = new Dictionary(StringComparer.Ordinal); private readonly System.Random _codeRandom = new System.Random(); /// /// /link — mint a one-time code for the caller and tell the sidecar about it. /// /// /// Chat, not console, and the reply is private. Both frameworks consume a /// / command rather than broadcasting it, and SendReply addresses one /// player — so neither the request nor the code reaches anybody else's chat. That is /// load-bearing rather than polish: a code read off a stream is a code somebody else /// can spend. /// /// /// /// The plugin does not record who is linked. The website is the author of /// record for that (R2), and a second copy here would be a second thing to keep in /// step across a wipe. So there is no "you are already linked" check on this path — /// the site refuses a Steam id it already holds, where it can say which account holds /// it. /// /// [ChatCommand("link")] private void CmdLinkChat(BasePlayer player, string command, string[] args) { if (player == null) return; string steamId = player.UserIDString; if (string.IsNullOrEmpty(steamId)) return; DateTime last; if (_lastLinkRequest.TryGetValue(steamId, out last) && DateTime.UtcNow - last < RequestCooldown) { SendReply(player, "Please wait a moment before asking for another link code."); return; } // One outstanding code per player: drop any earlier one so only the newest works. // Without this a player who ran /link three times leaves three live codes behind. DropCodesFor(steamId); string code = MintCode(); _codes[code] = new PendingLink { SteamId = steamId, Name = player.displayName, Expires = DateTime.UtcNow + CodeTtl }; _lastLinkRequest[steamId] = DateTime.UtcNow; Dictionary frame = Frame("account.link.requested", "event"); frame["steamId"] = steamId; frame["name"] = player.displayName; frame["ttlSec"] = (long)CodeTtl.TotalSeconds; // **The code is NOT in this frame, and that is the point.** The event exists so an // operator can see that linking is being used and so the site can rate-limit a player // who is fishing; it is not how the code travels. The code travels through the player, // which is what makes typing it proof that they are the one who asked. EmitFrame(frame); SendReply(player, "Your link code is " + code + "."); SendReply( player, "Enter it on the website within " + (int)CodeTtl.TotalMinutes + " minutes to link this Steam account. It works once."); } /// /// /unlink — ask the website to sever this Steam id's link. /// /// /// It emits rather than deletes, because the plugin holds no link to delete. The /// site owns the record and applies this like any other event off the feed. /// /// /// /// It exists because the site refuses to move a Steam id that another account /// already holds. Without a way out, a player who linked the wrong account while logged /// in as it would need staff; with this they walk to a computer they are already sitting /// at. The authority is the Steam id itself — whoever is connected as it is who it is. /// /// [ChatCommand("unlink")] private void CmdUnlinkChat(BasePlayer player, string command, string[] args) { if (player == null) return; string steamId = player.UserIDString; if (string.IsNullOrEmpty(steamId)) return; DropCodesFor(steamId); Dictionary frame = Frame("account.unlinked", "event"); frame["steamId"] = steamId; frame["name"] = player.displayName; frame["origin"] = "in-game"; EmitFrame(frame); SendReply( player, "Asked the website to unlink this Steam account. If it was linked, it is not now."); } /// /// Inbound link.confirm — the website has been given a code and wants to know /// whose it is. /// /// /// This is the first command the website originates in this bridge; everything /// before protocol 3 was the game talking. The reply carries the Steam id and nothing /// the caller did not already prove — there is no session here, no account creation, /// and no write to the game. /// /// /// /// A code is consumed on the first lookup whether or not it turns out to be /// expired, so a code cannot be probed twice. That is why the removal happens before /// the expiry check rather than after it. /// /// private void HandleLinkConfirm(Dictionary cmd, string reqId) { object codeObj; string code = cmd.TryGetValue("code", out codeObj) && codeObj != null ? codeObj.ToString().Trim() : null; if (string.IsNullOrEmpty(code)) { EmitLinkError(reqId, "malformed"); return; } PendingLink pending; if (!_codes.TryGetValue(code, out pending)) { EmitLinkError(reqId, "unknown"); return; } _codes.Remove(code); if (DateTime.UtcNow > pending.Expires) { EmitLinkError(reqId, "expired"); return; } Dictionary ok = Frame("link.ok", "reply"); ok["steamId"] = pending.SteamId; ok["name"] = pending.Name; if (reqId != null) ok["reqId"] = reqId; EmitFrame(ok); } /// /// A refusal, with a reason the website can turn into a sentence. /// /// /// unknown and expired are deliberately separate here and /// deliberately the same to the player: an operator reading a log wants to know /// whether codes are being guessed or merely going stale, and a stranger typing codes /// must not learn which of the two they hit. /// /// private void EmitLinkError(string reqId, string reason) { Dictionary frame = Frame("link.error", "reply"); frame["reason"] = reason; if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } /// Drops every outstanding code belonging to one Steam id. private void DropCodesFor(string steamId) { List stale = null; foreach (KeyValuePair entry in _codes) { if (entry.Value != null && entry.Value.SteamId == steamId) { if (stale == null) stale = new List(); stale.Add(entry.Key); } } if (stale == null) return; foreach (string key in stale) _codes.Remove(key); } /// /// Drops expired codes and stale cooldowns. /// /// /// Run on a timer rather than only on lookup, because an unconfirmed code is never /// looked up — a player who runs /link and wanders off leaves an entry that /// nothing else would ever remove. /// /// private void PurgeExpiredCodes() { DateTime now = DateTime.UtcNow; List dead = null; foreach (KeyValuePair entry in _codes) { if (entry.Value == null || now > entry.Value.Expires) { if (dead == null) dead = new List(); dead.Add(entry.Key); } } if (dead != null) { foreach (string key in dead) _codes.Remove(key); } List cold = null; foreach (KeyValuePair entry in _lastLinkRequest) { if (now - entry.Value > RequestCooldown) { if (cold == null) cold = new List(); cold.Add(entry.Key); } } if (cold == null) return; foreach (string key in cold) _lastLinkRequest.Remove(key); } /// /// A code from , retried until it does not collide. /// /// /// System.Random rather than a cryptographic source, and the reason it is /// defensible is the shape of the guess rather than the shape of the generator: a code /// lives five minutes, is consumed on first lookup, and the website rate-limits the /// confirm endpoint. An attacker who could predict the sequence would still have to /// reach a code before the player does. /// /// private string MintCode() { for (int attempt = 0; attempt < 16; attempt++) { var chars = new char[CodeLength]; for (int i = 0; i < CodeLength; i++) chars[i] = CodeAlphabet[_codeRandom.Next(CodeAlphabet.Length)]; string candidate = new string(chars); if (!_codes.ContainsKey(candidate)) return candidate; } // Sixteen collisions in a row against a table this small is not bit-space exhaustion, // it is something being wrong. A code that cannot collide is better than a loop that // might not end. return Guid.NewGuid().ToString("N").Substring(0, CodeLength).ToUpperInvariant(); } // ---- site-owned permissions (protocol 4) ---- // // R2: the website is the author of record for groups and grants, and this framework's own // permission store is an ENFORCEMENT CACHE. Every third-party plugin honours a site grant // with no adapter, because they all already call `UserHasPermission`. // // ── One verb, and the plugin does the diffing (D32) ─────────────────────── // // The site sends the whole set it authors for this server; this plugin compares it against // the live store and writes only what differs. It is the same shape as a board, pointed the // other way: one producer, re-sent on connect, reconciled by the receiver. // // The alternative — shipping this server's permission store to the website and diffing // there — was rejected for two reasons. The store is the bigger of the two sets and would // cross the wire constantly, and a website holding a copy of it has a second source of // truth that is stale the moment it lands. // // ── The site must not be able to stall the game, either ─────────────────── // // This is the first command whose work is UNBOUNDED: a community with two thousand linked // players has thousands of grants in one frame, and applying them in the tick the frame // arrives is a freeze the operator will blame on the game. So a sync is compiled into a // list of single-store operations and drained a few hundred at a time on a timer, and the // report goes back when the last one lands. // // That is the same rule the outbound queue lives under (§5), applied to the inbound half: // **nothing the far side sends may cost the main thread an unbounded amount of work.** // // ── What it reports, and what it will not do ────────────────────────────── // // • A permission this server has not REGISTERED is reported `unresolved` and skipped. // `GrantUserPermission` silently no-ops for an unregistered name — void, no throw, no // log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing // one plugin is a silent partial. Registering the name here instead would fabricate a // permission the operator never installed (D33). // • A holder the site does not author is reported `foreign` and never touched (D31). // Somebody typing `oxide.grant` during an incident is drift, not an error, and the // website offers an operator two answers to it. // • A group member who has never connected is reported `pending`. The store has no user // record to put in a group yet (§12.2 rule 4), and the membership lands on their first // connection — a direct grant would have reached them immediately, and pretending the // two are the same thing is how a site promises access it did not give. /// /// How many store operations one timer step performs. /// /// /// Each is a dictionary lookup and, at most, one write to an in-memory structure the /// framework flushes on its own schedule — microseconds. Two hundred of them is well /// under a frame at 60fps, and it drains ten thousand operations in about two and a /// half seconds, which is inside the sidecar's reply timeout. /// /// private const int PermOpsPerStep = 200; /// How often the sync drain runs while a job is in flight. private const float PermStepSeconds = 0.05f; /// /// The ceiling on one sync, in operations. /// /// /// A frame larger than this is refused whole rather than applied for a while and then /// abandoned. The website has its own limit below this one, so reaching it means /// something is wrong rather than something is big — and a half-applied permission set /// is the state nobody can reason about. /// /// private const int PermMaxOps = 20000; /// The operations a compiled sync is made of. One store question each. private enum PermOpKind { EnsureGroup, EnsureGroupPermission, EnsureMember, EnsureGrant, RetireGroupPermission, RetireMember, RetireGrant, RetireGroup, ScanPermission, ScanGroup } private struct PermOp { public PermOpKind Kind; /// A Steam id, or a group name — whichever the op is about. public string Subject; /// A permission name, a group name, or null. public string Object; /// Only uses these. public string Title; public int Rank; } /// /// One sync in flight. There is at most one: a second arriving while this one drains is /// answered `busy` rather than interleaved, because two writers reconciling the same /// store against two different desired sets is a race with no correct outcome. /// private sealed class PermSyncJob { public string ReqId; public string SetId; public List Ops = new List(); public int Next; public int Grants; public int Revokes; public int GroupsCreated; public int GroupsRemoved; public int GroupPermissions; public int GroupPermissionsRemoved; public int Members; public int MembersRemoved; public int AlreadyCorrect; public int Absent; public readonly List Unresolved = new List(); public readonly List Foreign = new List(); public readonly List Pending = new List(); /// Permission names already reported unresolved, so each is reported once. public readonly HashSet UnresolvedSeen = new HashSet(); /// Who the site says should hold each managed permission, for the foreign scan. public readonly Dictionary> DesiredHolders = new Dictionary>(); /// Who the site says is in each managed group, and what it should carry. public readonly Dictionary> DesiredMembers = new Dictionary>(); public readonly Dictionary> DesiredGroupPermissions = new Dictionary>(); } private PermSyncJob _permJob; private Timer _permTimer; /// /// True while this plugin is writing to the permission store. /// /// /// The framework raises OnUserPermissionGranted and friends for every write, /// including ours — so without this flag every sync would announce itself as drift and /// the website would chase its own tail. It is set around each drain step rather than /// for the whole job, so a write by somebody else BETWEEN steps is still seen. /// /// private bool _applyingPermissions; /// The last report, for rg.perms. Diagnostics only. private string _lastPermSummary = "none yet"; /// /// Main thread. Everything this server knows about permissions and groups — the option /// source the website's authoring form is built from (D33). /// /// /// It is a live read rather than anything cached, because the answer changes when an /// operator loads a plugin and the whole point of asking is to offer names that will /// actually resolve. /// /// private void HandlePermCatalogue(string reqId) { Dictionary reply = Frame("perm.catalogue", "reply"); var permissions = new List(); string[] registered = permission.GetPermissions(); if (registered != null) { foreach (string name in registered) { if (!string.IsNullOrEmpty(name)) permissions.Add(name); } } var groups = new List(); string[] groupNames = permission.GetGroups(); if (groupNames != null) { foreach (string name in groupNames) { if (string.IsNullOrEmpty(name)) continue; var row = new Dictionary { { "name", name }, { "title", permission.GetGroupTitle(name) }, { "rank", permission.GetGroupRank(name) } }; var granted = new List(); string[] groupPerms = permission.GetGroupPermissions(name, false); if (groupPerms != null) { foreach (string perm in groupPerms) { if (!string.IsNullOrEmpty(perm)) granted.Add(perm); } } row["permissions"] = granted; groups.Add(row); } } reply["permissions"] = permissions; reply["groups"] = groups; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// Main thread. Compiles a desired set into operations and starts draining it. /// /// /// Nothing is written here. Compiling is pure bookkeeping over the frame, so a /// malformed or enormous sync is refused before it has touched the store — there is no /// state to unwind because none was made. /// /// private void HandlePermSync(Dictionary cmd, string reqId) { if (_permJob != null) { EmitPermError(reqId, "busy"); return; } var job = new PermSyncJob { ReqId = reqId, SetId = StringField(cmd, "setId") }; // ── The desired set ──────────────────────────────────────────────── // // Order matters and it is not cosmetic: a group has to exist before anything can be // put in it, and a permission has to be on the group before a member inherits it. foreach (Dictionary group in Rows(cmd, "groups")) { string name = Lower(StringField(group, "name")); if (string.IsNullOrEmpty(name)) continue; job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroup, Subject = name, Title = StringField(group, "title") ?? name, Rank = IntField(group, "rank") }); HashSet wanted = Bucket(job.DesiredGroupPermissions, name); foreach (string perm in Strings(group, "permissions")) { string lowered = Lower(perm); if (string.IsNullOrEmpty(lowered)) continue; wanted.Add(lowered); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroupPermission, Subject = name, Object = lowered }); } foreach (string member in Strings(group, "members")) { if (string.IsNullOrEmpty(member)) continue; Bucket(job.DesiredMembers, name).Add(member); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureMember, Subject = member, Object = name }); } // A group with no members still has to be scanned: the interesting case is a // member the site did not put there. Bucket(job.DesiredMembers, name); } foreach (Dictionary grant in Rows(cmd, "grants")) { string steamId = StringField(grant, "steamId"); if (string.IsNullOrEmpty(steamId)) continue; foreach (string perm in Strings(grant, "permissions")) { string lowered = Lower(perm); if (string.IsNullOrEmpty(lowered)) continue; Bucket(job.DesiredHolders, lowered).Add(steamId); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGrant, Subject = steamId, Object = lowered }); } } // ── What the site has withdrawn ──────────────────────────────────── // // The site remembers what it put here, so a grant it has since removed arrives as an // explicit retirement. It cannot be inferred from the desired set: a name that is not // in it is either something the site withdrew or something a human granted, and those // two have opposite correct answers (D31). foreach (Dictionary row in Rows(cmd, "retire")) { string kind = StringField(row, "kind"); string subject = StringField(row, "subject"); string target = Lower(StringField(row, "object")); if (string.IsNullOrEmpty(kind) || string.IsNullOrEmpty(subject)) continue; switch (kind) { case "grant": job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGrant, Subject = subject, Object = target }); break; case "member": job.Ops.Add(new PermOp { Kind = PermOpKind.RetireMember, Subject = subject, Object = target }); break; case "group-permission": job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroupPermission, Subject = Lower(subject), Object = target }); break; case "group": job.Ops.Add(new PermOp { Kind = PermOpKind.RetireGroup, Subject = Lower(subject) }); break; } } // ── The namespace the site claims ────────────────────────────────── // // Foreign holders can only be found for names the site says it manages. A permission it // does not claim is somebody else's business entirely and is never enumerated — which // also bounds this scan by the site's own set rather than by the size of the store. foreach (string perm in Strings(cmd, "managed")) { string lowered = Lower(perm); if (string.IsNullOrEmpty(lowered)) continue; Bucket(job.DesiredHolders, lowered); job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered }); } foreach (string group in job.DesiredMembers.Keys) job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); if (job.Ops.Count > PermMaxOps) { EmitPermError(reqId, "too-large"); Puts($"permission sync refused: {job.Ops.Count} operations exceeds {PermMaxOps}"); return; } _permJob = job; _permTimer = timer.Every(PermStepSeconds, StepPermSync); // Run the first slice now rather than waiting out a tick: a small sync — which is every // ordinary one — then finishes inside the call the website is waiting on. StepPermSync(); } /// /// Main thread. Drains up to operations and, on the last /// one, sends the report. /// private void StepPermSync() { PermSyncJob job = _permJob; if (job == null) { StopPermTimer(); return; } _applyingPermissions = true; try { int budget = PermOpsPerStep; while (budget-- > 0 && job.Next < job.Ops.Count) { PermOp op = job.Ops[job.Next++]; try { Apply(job, op); } catch (Exception ex) { // One bad operation must not abandon the set. The report still goes back, // and the next sync retries what this one could not do. Puts($"permission op {op.Kind} on '{op.Subject}' threw: {ex.Message}"); } } } finally { _applyingPermissions = false; } if (job.Next < job.Ops.Count) return; _permJob = null; StopPermTimer(); SendPermReport(job); } private void StopPermTimer() { if (_permTimer == null) return; _permTimer.Destroy(); _permTimer = null; } /// Main thread. One store question, and at most one store write. private void Apply(PermSyncJob job, PermOp op) { switch (op.Kind) { case PermOpKind.EnsureGroup: if (permission.GroupExists(op.Subject)) { job.AlreadyCorrect++; } else { permission.CreateGroup(op.Subject, op.Title, op.Rank); job.GroupsCreated++; } break; case PermOpKind.EnsureGroupPermission: if (!Registered(job, op.Object)) break; if (permission.GroupHasPermission(op.Subject, op.Object)) { job.AlreadyCorrect++; } else { permission.GrantGroupPermission(op.Subject, op.Object, this); job.GroupPermissions++; } break; case PermOpKind.EnsureMember: if (permission.UserHasGroup(op.Subject, op.Object)) { job.AlreadyCorrect++; break; } permission.AddUserGroup(op.Subject, op.Object); // Read back, because this is the call that can do nothing at all. A player the // store has never seen has no user record to put in a group (§12.2 rule 4), and // the API says so by returning void and changing nothing. if (permission.UserHasGroup(op.Subject, op.Object)) job.Members++; else job.Pending.Add(op.Subject + ":" + op.Object); break; case PermOpKind.EnsureGrant: if (!Registered(job, op.Object)) break; if (HoldsDirectly(op.Subject, op.Object)) { job.AlreadyCorrect++; } else { permission.GrantUserPermission(op.Subject, op.Object, this); job.Grants++; } break; case PermOpKind.RetireGrant: if (HoldsDirectly(op.Subject, op.Object)) { permission.RevokeUserPermission(op.Subject, op.Object); job.Revokes++; } else { job.Absent++; } break; case PermOpKind.RetireMember: if (permission.UserHasGroup(op.Subject, op.Object)) { permission.RemoveUserGroup(op.Subject, op.Object); job.MembersRemoved++; } else { job.Absent++; } break; case PermOpKind.RetireGroupPermission: if (permission.GroupHasPermission(op.Subject, op.Object)) { permission.RevokeGroupPermission(op.Subject, op.Object); job.GroupPermissionsRemoved++; } else { job.Absent++; } break; case PermOpKind.RetireGroup: if (permission.GroupExists(op.Subject)) { permission.RemoveGroup(op.Subject); job.GroupsRemoved++; } else { job.Absent++; } break; case PermOpKind.ScanPermission: ScanPermission(job, op.Subject); break; case PermOpKind.ScanGroup: ScanGroup(job, op.Subject); break; } } /// /// Everybody holding a managed permission DIRECTLY, minus everybody the site says /// should — the rest is a hand edit, and it is reported rather than undone. /// private void ScanPermission(PermSyncJob job, string perm) { if (!permission.PermissionExists(perm)) return; HashSet desired; if (!job.DesiredHolders.TryGetValue(perm, out desired)) desired = new HashSet(); string[] holders = permission.GetPermissionUsers(perm); if (holders == null) return; foreach (string entry in holders) { string id = UserId(entry); if (string.IsNullOrEmpty(id) || desired.Contains(id)) continue; job.Foreign.Add(new Dictionary { { "kind", "grant" }, { "subject", id }, { "object", perm } }); } } /// /// The same question about a managed group, asked twice: who is in it that the site did /// not put there, and what does it carry that the site did not put on it. /// private void ScanGroup(PermSyncJob job, string group) { if (!permission.GroupExists(group)) return; HashSet members; if (!job.DesiredMembers.TryGetValue(group, out members)) members = new HashSet(); string[] present = permission.GetUsersInGroup(group); if (present != null) { foreach (string entry in present) { string id = UserId(entry); if (string.IsNullOrEmpty(id) || members.Contains(id)) continue; job.Foreign.Add(new Dictionary { { "kind", "member" }, { "subject", id }, { "object", group } }); } } HashSet wanted; if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted)) wanted = new HashSet(); string[] carried = permission.GetGroupPermissions(group, false); if (carried == null) return; foreach (string perm in carried) { string lowered = Lower(perm); if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered)) continue; job.Foreign.Add(new Dictionary { { "kind", "group-permission" }, { "subject", group }, { "object", lowered } }); } } /// /// Whether this server has a plugin that registered the name, recording the answer once /// per sync when it has not. /// /// /// This is the pre-check PLAN.md §12.2 rule 1 exists for. Without it the grant below /// would return void, throw nothing, log nothing and do nothing, and the website would /// record a privilege it never gave. /// /// private bool Registered(PermSyncJob job, string perm) { if (string.IsNullOrEmpty(perm)) return false; if (permission.PermissionExists(perm)) return true; if (job.UnresolvedSeen.Add(perm)) job.Unresolved.Add(perm); return false; } /// /// Whether a player holds a permission through a grant of their own, as opposed to /// through a group. /// /// /// UserHasPermission cannot answer this — it is true for a member of a group /// that carries the permission, so using it would make every direct grant look already /// applied the moment a group covered it, and a revoke look already done. /// GetPermissionUsers reads the user's own set, which is the question. /// /// private bool HoldsDirectly(string steamId, string perm) { string[] holders = permission.GetPermissionUsers(perm); if (holders == null) return false; foreach (string entry in holders) { if (UserId(entry) == steamId) return true; } return false; } /// /// The id out of a store listing entry. /// /// /// Both GetPermissionUsers and GetUsersInGroup answer with the id and the /// player's last known name stuck together — 76561198…(Wanderer) — and the /// spacing differs between the two calls and between the frameworks. So the id is /// everything before the first bracket, and a name containing one cannot confuse it /// because only the first is looked for. /// /// private static string UserId(string entry) { if (string.IsNullOrEmpty(entry)) return null; int bracket = entry.IndexOf('('); string id = bracket >= 0 ? entry.Substring(0, bracket) : entry; return id.Trim(); } private void SendPermReport(PermSyncJob job) { var applied = new Dictionary { { "grants", job.Grants }, { "revokes", job.Revokes }, { "groupsCreated", job.GroupsCreated }, { "groupsRemoved", job.GroupsRemoved }, { "groupPermissions", job.GroupPermissions }, { "groupPermissionsRemoved", job.GroupPermissionsRemoved }, { "members", job.Members }, { "membersRemoved", job.MembersRemoved } }; Dictionary report = Frame("perm.report", "reply"); report["applied"] = applied; report["alreadyCorrect"] = job.AlreadyCorrect; report["absent"] = job.Absent; report["unresolved"] = job.Unresolved; report["foreign"] = job.Foreign; report["pending"] = job.Pending; report["operations"] = job.Ops.Count; if (!string.IsNullOrEmpty(job.SetId)) report["setId"] = job.SetId; if (job.ReqId != null) report["reqId"] = job.ReqId; _lastPermSummary = $"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " + $"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " + $"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " + $"pending={job.Pending.Count} at={DateTime.UtcNow:HH:mm:ss}Z"; EmitFrame(report); } /// A refusal of the whole sync, with a reason the website turns into a sentence. private void EmitPermError(string reqId, string reason) { Dictionary frame = Frame("perm.error", "reply"); frame["reason"] = reason; if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } // ---- drift, as it happens ---- // // Both frameworks raise a hook for every permission write, ours included. They are NOT the // drift record — the sync report is, because only the site's desired set can say whether a // grant is foreign. What they are is a reason to reconcile SOON: an operator typing // `oxide.grant` at the console shows up on the website in seconds instead of at the next // scheduled audit, and the authoritative answer still comes from a sync. // // That division is what makes them safe to trust at exactly the weight they deserve. A hook // that stops firing on a framework upgrade costs latency, not correctness. private void OnUserPermissionGranted(string id, string permName) { MarkHook("OnUserPermissionGranted"); EmitPermDrift("granted", id, permName, null); } private void OnUserPermissionRevoked(string id, string permName) { MarkHook("OnUserPermissionRevoked"); EmitPermDrift("revoked", id, permName, null); } private void OnUserGroupAdded(string id, string groupName) { MarkHook("OnUserGroupAdded"); EmitPermDrift("group-added", id, null, groupName); } private void OnUserGroupRemoved(string id, string groupName) { MarkHook("OnUserGroupRemoved"); EmitPermDrift("group-removed", id, null, groupName); } private void OnGroupPermissionGranted(string name, string permName) { MarkHook("OnGroupPermissionGranted"); EmitPermDrift("group-permission-granted", null, permName, name); } private void OnGroupPermissionRevoked(string name, string permName) { MarkHook("OnGroupPermissionRevoked"); EmitPermDrift("group-permission-revoked", null, permName, name); } // ---- the reload, watched ---- // // These two are why an automatic rollback is possible at all (R18): a reload either // produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin // that is DOWN because of an edit the website just made. // // They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from // either: a busy server loads and unloads plugins for reasons that have nothing to do with // us, and a frame per event would be noise on the wire and a trigger nobody asked for. private void OnPluginLoaded(Plugin plugin) { MarkHook("OnPluginLoaded"); ConfigWriteJob job = _configJob; if (job == null || plugin == null) return; if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) ConfigReloadArrived(); } private void OnPluginUnloaded(Plugin plugin) { MarkHook("OnPluginUnloaded"); ConfigWriteJob job = _configJob; if (job == null || plugin == null) return; // Only ever used to word the failure. "It unloaded and did not come back" is a // compile error in the edited file; "it never reloaded" is a framework that did not // act on the request at all, and those send an operator to different places. if (string.Equals(plugin.Name, job.Reload, StringComparison.OrdinalIgnoreCase)) job.Unloaded = true; } /// /// Main thread. Says that the store changed under us, and by how much it is worth /// caring. /// /// /// Staff class (§8.5): it names a Steam id beside a privilege, which is a fact about a /// person's standing rather than about what happened on the server. /// /// private void EmitPermDrift(string action, string steamId, string perm, string group) { // Our own writes raise these hooks too. Suppressing them here rather than filtering on // the website is the only version that works: the site cannot tell its own grant from // a human's by looking at one, which is the entire reason drift needs a desired set. if (_applyingPermissions) return; Dictionary frame = Frame("perm.drift", "event"); frame["action"] = action; if (!string.IsNullOrEmpty(steamId)) frame["steamId"] = steamId; if (!string.IsNullOrEmpty(perm)) frame["permission"] = perm; if (!string.IsNullOrEmpty(group)) frame["group"] = group; EmitFrame(frame); } // ---- protocol 5: plugin configuration ---- // // R18. An admin edits a loaded plugin's settings on the website and the plugin reloads. // Three request/reply verbs: `config.list` describes the tree, `config.read` returns one // file, `config.write` replaces a set and reloads whatever owns it. // // Four properties live on this side because only the game host can hold them: // // * **The roots come from the framework, never from a literal.** `ConfigDirectory` is // `oxide/config` on Oxide, `carbon/configs` on Carbon, and neither on a server whose // operator moved it (`CARBON.md` §3). That amendment was proven the best way it could // have been: this plugin's own config landed in both places, from this same file. // * **`DataDirectory` is never walked.** It holds live state — kit cooldowns, zone // definitions — and both frameworks' permission stores, which is R2's mirror one // directory over. A settings editor that strayed there would be editing phase 7 // underneath itself. // * **A reload is observable.** `OnPluginLoaded` is a real hook, so "did that work" has // an answer rather than an assumption — and an answer is the whole reason an automatic // rollback is possible at all. // * **The site composes the bytes; this plugin writes them.** It receives whole file // text, not a key and a value, because JavaScript cannot tell `1` from `1.0` and Oxide // deserializes into typed C# classes (D35/D36). Everything this end does to a document // is check that it parses. /// How deep the walk goes before it stops trying to describe a tree. private const int ConfigMaxDepth = 6; /// How many files the walk will report. private const int ConfigMaxFiles = 500; /// /// The largest file this plugin will read or write. /// /// /// A file past it is reported and marked un-editable with the reason, never /// omitted: an operator who cannot find a file they know exists goes looking for a bug /// in the bridge, and one who can see why it was refused does not. /// /// private const int ConfigMaxFileBytes = 256 * 1024; /// Files one write may touch. A plugin can own several; ten of them is not a tree. private const int ConfigMaxWriteFiles = 10; /// /// How long a reload has to produce OnPluginLoaded before the write is undone. /// /// /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than /// taste. The worst path through one write is two of these windows — wait for the /// edit's reload, give up, restore, wait for the restore's reload — with a caller /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link /// sidecar/src/rpc.rs). A window that does not leave room for both makes the /// rollback report arrive after the only thing waiting to hear it has given up, which /// is the one outcome worse than no rollback: the plugin is fine and nobody knows. /// /// private const float ConfigReloadWindowSeconds = 4f; /// How much of the newest log file is read back when a reload does not arrive. private const int ConfigLogTailBytes = 8 * 1024; /// One file, resolved and checked, on its way to disk. private sealed class StagedConfigWrite { public string RelPath; public string FullPath; public string Text; /// What was there before, held for the rollback that may never be needed. public string Original; public bool Written; } /// /// A write that has landed and is waiting to hear whether the plugin came back. /// /// /// At most one exists at a time. A second write arriving mid-window is refused rather /// than queued: two overlapping rollbacks over intersecting file sets can restore a /// file to a state that was never on disk. /// /// private sealed class ConfigWriteJob { public string ReqId; public string Reload; public List Staged; public Timer Watch; /// Whether the target was seen leaving. It separates "never started" from "did not come back". public bool Unloaded; } private ConfigWriteJob _configJob; private string _lastConfigSummary = "none yet"; /// Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19). private static bool? _carbon; private void HandleConfigList(string reqId) { string root = ConfigRoot(); if (string.IsNullOrEmpty(root) || !Directory.Exists(root)) { EmitConfigError(reqId, "no-root", "this framework reports no configuration directory"); return; } var files = new List(); bool truncated = false; try { WalkConfig(root, root, 0, files, ref truncated); } catch (Exception ex) { EmitConfigError(reqId, "unreadable", ex.Message); return; } Dictionary reply = Frame("config.catalogue", "reply"); reply["root"] = root; reply["files"] = files; reply["plugins"] = LoadedPlugins(); reply["truncated"] = truncated; // Named rather than inferred, so the website's locked-key guard (D38) is written // against what this plugin calls itself instead of against a filename somebody may // rename. The file carrying the link is not a file the site may break from a form. reply["self"] = Name; reply["limits"] = new Dictionary { { "depth", ConfigMaxDepth }, { "files", ConfigMaxFiles }, { "fileBytes", ConfigMaxFileBytes }, { "writeFiles", ConfigMaxWriteFiles } }; if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// Main thread, and deliberately cheap: it reads directory entries, never file /// contents. A tree of 500 files costs 500 stat calls here; hashing all of them would /// cost up to 128 MB of reads in one frame, which is exactly the unbounded work §10.5 /// forbids. A file's version is therefore produced by config.read, on the one /// file somebody actually opened. /// private void WalkConfig(string root, string dir, int depth, List into, ref bool truncated) { if (depth > ConfigMaxDepth) { truncated = true; return; } string[] entries; try { entries = Directory.GetFiles(dir, "*.json"); } catch (Exception) { truncated = true; return; } Array.Sort(entries, StringComparer.OrdinalIgnoreCase); foreach (string full in entries) { if (into.Count >= ConfigMaxFiles) { truncated = true; return; } FileInfo info; try { info = new FileInfo(full); } catch (Exception) { continue; } // A link is not followed even when it points somewhere legitimate. Resolving one // is how a tree that looks bounded turns out not to be, and how a path that // validates as "under the root" writes somewhere else. if ((info.Attributes & FileAttributes.ReparsePoint) != 0) continue; string rel = RelativeConfigPath(root, full); var row = new Dictionary { { "path", rel }, { "bytes", info.Length }, { "modified", ToUnixMs(info.LastWriteTimeUtc) }, { "plugin", PluginGuess(rel) } }; if (info.Length > ConfigMaxFileBytes) { row["editable"] = false; row["reason"] = "larger than this bridge will carry"; } else { row["editable"] = true; } into.Add(row); } string[] dirs; try { dirs = Directory.GetDirectories(dir); } catch (Exception) { truncated = true; return; } Array.Sort(dirs, StringComparer.OrdinalIgnoreCase); foreach (string sub in dirs) { if (into.Count >= ConfigMaxFiles) { truncated = true; return; } try { if ((new DirectoryInfo(sub).Attributes & FileAttributes.ReparsePoint) != 0) continue; } catch (Exception) { continue; } WalkConfig(root, sub, depth + 1, into, ref truncated); } } private void HandleConfigRead(Dictionary cmd, string reqId) { string rel = StringField(cmd, "path"); string full; string refusal; if (!ResolveConfigPath(rel, out full, out refusal)) { EmitConfigError(reqId, "path", refusal); return; } if (!File.Exists(full)) { EmitConfigError(reqId, "missing", "no such configuration file"); return; } string text; long bytes; DateTime modified; try { var info = new FileInfo(full); bytes = info.Length; modified = info.LastWriteTimeUtc; if (bytes > ConfigMaxFileBytes) { EmitConfigError(reqId, "too-large", "larger than this bridge will carry"); return; } text = File.ReadAllText(full, Encoding.UTF8); } catch (Exception ex) { EmitConfigError(reqId, "unreadable", ex.Message); return; } Dictionary reply = Frame("config.file", "reply"); reply["path"] = rel; reply["text"] = text; reply["version"] = VersionOf(text); reply["bytes"] = bytes; reply["modified"] = ToUnixMs(modified); reply["plugin"] = PluginGuess(rel); if (reqId != null) reply["reqId"] = reqId; EmitFrame(reply); } /// /// Main thread. Replaces a set of files and reloads what owns them. /// /// /// Everything is resolved, version-checked and parsed before the first byte is /// written — the same posture as perm.sync, and for the same reason: a /// refusal that has touched nothing has nothing to unwind. What cannot be checked in /// advance is whether the plugin will come back, which is what the window and the /// rollback are for. /// /// private void HandleConfigWrite(Dictionary cmd, string reqId) { if (_configJob != null) { EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload"); return; } var rows = new List>(Rows(cmd, "files")); if (rows.Count == 0) { EmitConfigError(reqId, "empty", "a write must name at least one file"); return; } if (rows.Count > ConfigMaxWriteFiles) { EmitConfigError(reqId, "too-many", $"a write may touch at most {ConfigMaxWriteFiles} files"); return; } string reload = StringField(cmd, "reload"); // The site may not reload the bridge. The reload would unload this plugin, close the // link the answer travels on, and leave a rollback with nothing watching it — so the // one failure this whole mechanism exists to prevent would be the one it could not // report. Our own settings are still editable (D38); they take effect the next time // somebody reloads this plugin deliberately. if (!string.IsNullOrEmpty(reload) && reload.Equals(Name, StringComparison.OrdinalIgnoreCase)) { EmitConfigError(reqId, "reload-self", "this bridge will not reload itself: the reload would close the link carrying the answer"); return; } var staged = new List(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (Dictionary row in rows) { string rel = StringField(row, "path"); string version = StringField(row, "version"); string text = StringField(row, "text"); string full; string refusal; if (!ResolveConfigPath(rel, out full, out refusal)) { EmitConfigError(reqId, "path", refusal); return; } if (!seen.Add(full)) { EmitConfigError(reqId, "duplicate", $"'{rel}' is named twice in one write"); return; } if (text == null) { EmitConfigError(reqId, "empty", $"'{rel}' carries no text"); return; } if (Encoding.UTF8.GetByteCount(text) > ConfigMaxFileBytes) { EmitConfigError(reqId, "too-large", $"'{rel}' is larger than this bridge will carry"); return; } // A config file is edited, never created. A plugin writes its own defaults, and a // file this walk has never seen is not a settings file somebody forgot — it is a // path we have no reason to believe in. if (!File.Exists(full)) { EmitConfigError(reqId, "missing", $"'{rel}' does not exist"); return; } string current; try { current = File.ReadAllText(full, Encoding.UTF8); } catch (Exception ex) { EmitConfigError(reqId, "unreadable", $"'{rel}': {ex.Message}"); return; } string actual = VersionOf(current); if (version != actual) { Dictionary conflict = Frame("config.error", "reply"); conflict["reason"] = "conflict"; conflict["detail"] = $"'{rel}' changed on disk since it was read"; conflict["path"] = rel; conflict["version"] = actual; if (reqId != null) conflict["reqId"] = reqId; EmitFrame(conflict); return; } try { Newtonsoft.Json.Linq.JToken.Parse(text); } catch (Exception ex) { EmitConfigError(reqId, "invalid", $"'{rel}' is not valid JSON: {ex.Message}"); return; } staged.Add(new StagedConfigWrite { RelPath = rel, FullPath = full, Text = text, Original = current }); } foreach (StagedConfigWrite file in staged) { try { BackupConfig(file); File.WriteAllText(file.FullPath, file.Text, Utf8NoBom); file.Written = true; } catch (Exception ex) { // Half a set on disk is the state nothing can reason about. Put back whatever // did land before answering. RestoreConfig(staged); EmitConfigError(reqId, "unwritable", $"'{file.RelPath}': {ex.Message}"); return; } } if (string.IsNullOrEmpty(reload)) { SendConfigReport(reqId, staged, false, false, "no reload was asked for", null); return; } if (plugins.Find(reload) == null) { // The file is written and the plugin is not loaded, so there is nothing to reload // and nothing to roll back from: the change applies when it is next loaded. Said // plainly, because "saved" and "in effect" are different claims. SendConfigReport(reqId, staged, false, false, $"'{reload}' is not loaded", null); return; } _configJob = new ConfigWriteJob { ReqId = reqId, Reload = reload, Staged = staged }; _configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut); if (!ReloadPluginByName(reload)) { StopConfigWatch(); _configJob = null; RestoreConfig(staged); EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'"); } } /// Main thread. The reload arrived: the plugin is back, so the write stands. private void ConfigReloadArrived() { ConfigWriteJob job = _configJob; if (job == null) return; StopConfigWatch(); _configJob = null; SendConfigReport(job.ReqId, job.Staged, true, false, null, null); } /// /// Main thread. The window closed with no OnPluginLoaded, which means the plugin /// is down and the edit is why. Put every file back, reload again, and say so. /// /// /// This is the feature's real content. Without it, R18 is a web form that takes a /// required plugin off a production server one typo at a time. /// /// private void ConfigReloadTimedOut() { ConfigWriteJob job = _configJob; if (job == null) return; _configJob = null; StopConfigWatch(); RestoreConfig(job.Staged); string reason = job.Unloaded ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s" : $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s"; // Fire and forget: the restore is on disk either way, and waiting for a second window // would spend the caller's whole RPC budget to learn something the operator can see. ReloadPluginByName(job.Reload); SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail()); } private void StopConfigWatch() { ConfigWriteJob job = _configJob; if (job == null || job.Watch == null) return; job.Watch.Destroy(); job.Watch = null; } /// /// Main thread. What happened, and what is on disk now. /// /// /// The versions are re-read rather than assumed, because both frameworks merge missing /// defaults into a config on load and save it back — so the file after a successful /// reload is regularly not the file we wrote, and a site holding our version would /// conflict with itself on the next save. /// /// private void SendConfigReport( string reqId, List staged, bool reloaded, bool rolledBack, string reason, string log) { var files = new List(); foreach (StagedConfigWrite file in staged) { var row = new Dictionary { { "path", file.RelPath } }; try { string now = File.ReadAllText(file.FullPath, Encoding.UTF8); row["version"] = VersionOf(now); row["bytes"] = Encoding.UTF8.GetByteCount(now); row["rewritten"] = !rolledBack && now != file.Text; } catch (Exception) { row["version"] = null; } files.Add(row); } Dictionary report = Frame("config.report", "reply"); report["ok"] = !rolledBack; report["reloaded"] = reloaded; report["rolledBack"] = rolledBack; report["files"] = files; if (!string.IsNullOrEmpty(reason)) report["reason"] = reason; if (!string.IsNullOrEmpty(log)) report["log"] = log; if (reqId != null) report["reqId"] = reqId; _lastConfigSummary = $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + $"at={DateTime.UtcNow:HH:mm:ss}Z" + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); EmitFrame(report); } private void EmitConfigError(string reqId, string reason, string detail) { Dictionary frame = Frame("config.error", "reply"); frame["reason"] = reason; if (!string.IsNullOrEmpty(detail)) frame["detail"] = detail; if (reqId != null) frame["reqId"] = reqId; EmitFrame(frame); } /// Main thread. Puts every file that landed back the way it was. private void RestoreConfig(List staged) { foreach (StagedConfigWrite file in staged) { if (!file.Written) continue; try { File.WriteAllText(file.FullPath, file.Original, Utf8NoBom); } catch (Exception ex) { // Nothing else can be done from here, and it must be loud: the file on disk is // now the edit that took a plugin down, and the operator is the only one who // can fix it. Puts($"could not restore '{file.RelPath}': {ex.Message}"); } } } /// /// Keeps a copy of a file about to be overwritten, and the nine before it. /// /// /// Under DataDirectory, which is this plugin writing its own state — not the /// editor reaching into the tree it must never walk. The rollback itself does not /// depend on these files; it holds the original in memory. They are for the operator /// who wants last Tuesday's config back. /// /// private void BackupConfig(StagedConfigWrite file) { string dir = Path.Combine(Interface.Oxide.DataDirectory, Name, "config-backups"); Directory.CreateDirectory(dir); string flat = file.RelPath.Replace('/', '_'); string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); File.WriteAllText(Path.Combine(dir, $"{flat}.{stamp}.bak"), file.Original, Utf8NoBom); string[] kept; try { kept = Directory.GetFiles(dir, $"{flat}.*.bak"); } catch (Exception) { return; } if (kept.Length <= 10) return; Array.Sort(kept, StringComparer.Ordinal); for (int i = 0; i < kept.Length - 10; i++) { try { File.Delete(kept[i]); } catch (Exception) { } } } /// /// The tail of the newest log file, read only when a reload failed. /// /// /// A rollback that says "it did not come back" and nothing else sends the operator to a /// console to find the line that says why. This carries the line instead. Bounded, and /// paid for only on the failure path. /// /// private string LogTail() { try { string dir = Interface.Oxide.LogDirectory; if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) return null; string[] logs = Directory.GetFiles(dir, "*.txt"); if (logs.Length == 0) return null; string newest = null; DateTime newestAt = DateTime.MinValue; foreach (string candidate in logs) { DateTime at = File.GetLastWriteTimeUtc(candidate); if (at <= newestAt) continue; newest = candidate; newestAt = at; } if (newest == null) return null; using (var stream = new FileStream(newest, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { int take = (int)Math.Min(stream.Length, ConfigLogTailBytes); stream.Seek(-take, SeekOrigin.End); var buffer = new byte[take]; int read = stream.Read(buffer, 0, take); return Encoding.UTF8.GetString(buffer, 0, read); } } catch (Exception) { return null; } } /// /// Reloads a plugin through the framework rather than by composing a console command. /// /// /// The console is the one place the two frameworks genuinely differ — Carbon's commands /// are c.-prefixed and an alias for the Oxide names is opt-in — and a wrong /// prefix on Carbon prints nothing at all, so it looks exactly like a command that /// worked (`CARBON.md` §5). Reflection rather than #if CARBON, because one /// byte-identical source file on both frameworks is the property R19 proved and is /// worth keeping. /// /// private bool ReloadPluginByName(string name) { try { System.Reflection.MethodInfo reload = Interface.Oxide.GetType() .GetMethod("ReloadPlugin", new[] { typeof(string) }); if (reload != null) { reload.Invoke(Interface.Oxide, new object[] { name }); return true; } } catch (Exception ex) { Puts($"framework reload of '{name}' threw: {ex.Message}"); } try { covalence.Server.Command(IsCarbon() ? "c.reload" : "oxide.reload", name); return true; } catch (Exception ex) { Puts($"console reload of '{name}' threw: {ex.Message}"); return false; } } private static bool IsCarbon() { if (_carbon.HasValue) return _carbon.Value; bool found = false; try { foreach (System.Reflection.Assembly loaded in AppDomain.CurrentDomain.GetAssemblies()) { string assembly = loaded.GetName().Name; if (assembly == null) continue; if (assembly == "Carbon" || assembly.StartsWith("Carbon.", StringComparison.Ordinal)) { found = true; break; } } } catch (Exception) { } _carbon = found; return found; } private static string ConfigRoot() { try { return Interface.Oxide.ConfigDirectory; } catch (Exception) { return null; } } /// /// Turns a path from a web form into a path on this host, or refuses it with a reason. /// /// /// Phase 7 addressed things by name; this phase addresses them by path, which is /// exactly the change that introduces this bug class. Four refusals, and the last is the /// one that matters: a resolved path must genuinely sit under the config root, checked /// after canonicalisation rather than before. /// /// private static bool ResolveConfigPath(string rel, out string full, out string refusal) { full = null; refusal = null; if (string.IsNullOrEmpty(rel)) { refusal = "no path"; return false; } if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0) { refusal = "a path may not step outside the configuration directory"; return false; } if (rel.StartsWith("/", StringComparison.Ordinal) || rel.StartsWith("\\", StringComparison.Ordinal) || Path.IsPathRooted(rel) || rel.IndexOf(':') >= 0) { refusal = "a path must be relative to the configuration directory"; return false; } if (!rel.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) { refusal = "only .json files are configuration"; return false; } string root = ConfigRoot(); if (string.IsNullOrEmpty(root)) { refusal = "this framework reports no configuration directory"; return false; } string rooted; try { rooted = Path.GetFullPath(root); full = Path.GetFullPath(Path.Combine(rooted, rel.Replace('/', Path.DirectorySeparatorChar))); } catch (Exception ex) { refusal = ex.Message; full = null; return false; } string prefix = rooted.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ? rooted : rooted + Path.DirectorySeparatorChar; if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) { refusal = "that path is not inside the configuration directory"; full = null; return false; } try { var info = new FileInfo(full); if (info.Exists && (info.Attributes & FileAttributes.ReparsePoint) != 0) { refusal = "that file is a link, and a link is not followed"; full = null; return false; } } catch (Exception) { } return true; } private static string RelativeConfigPath(string root, string full) { string prefix = root.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) ? root : root + Path.DirectorySeparatorChar; string rel = full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ? full.Substring(prefix.Length) : Path.GetFileName(full); return rel.Replace(Path.DirectorySeparatorChar, '/').Replace('\\', '/'); } /// /// Which plugin a file probably belongs to: the folder for a nested file, the file's own /// name otherwise. /// /// /// A guess, and it is labelled one all the way to the form. A folder name is /// convention, not contract. Infer it silently and the failure is the nastiest available /// here: we reload the wrong plugin, watch OnPluginLoaded fire for it, and /// report success while the plugin that was actually edited never re-read a thing. /// /// private static string PluginGuess(string rel) { int slash = rel.IndexOf('/'); return slash > 0 ? rel.Substring(0, slash) : Path.GetFileNameWithoutExtension(rel); } private List LoadedPlugins() { var rows = new List(); Plugin[] all; try { all = plugins.GetAll(); } catch (Exception) { return rows; } if (all == null) return rows; foreach (Plugin loaded in all) { if (loaded == null || string.IsNullOrEmpty(loaded.Name)) continue; rows.Add(new Dictionary { { "name", loaded.Name }, { "title", loaded.Title }, { "version", loaded.Version.ToString() }, { "author", loaded.Author } }); } return rows; } /// /// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its /// text. /// /// /// Deliberately not a cryptographic digest. Nothing here is a security claim — the site /// never computes one of these, it only echoes back the one it was given — and /// System.Security.Cryptography is a namespace whose availability under a plugin /// compiler is one more thing that would have to be true on two frameworks. /// /// private static string VersionOf(string text) { byte[] bytes = Encoding.UTF8.GetBytes(text ?? string.Empty); ulong hash = 14695981039346656037UL; foreach (byte b in bytes) { hash ^= b; hash *= 1099511628211UL; } return $"{bytes.Length:x}-{hash:x16}"; } /// UTF-8 with no byte-order mark, which is what both frameworks write. private static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(false); private static long ToUnixMs(DateTime utc) { return (long)(utc - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalMilliseconds; } // ---- reading a command's fields ---- // // `JsonConvert` hands back `object`, and every field below arrives as one. These are the // three shapes this protocol uses, read defensively: a malformed field is a missing field, // never an exception on the main thread. private static string StringField(Dictionary row, string key) { object value; return row != null && row.TryGetValue(key, out value) && value != null ? value.ToString() : null; } private static int IntField(Dictionary row, string key) { object value; if (row == null || !row.TryGetValue(key, out value) || value == null) return 0; int parsed; return int.TryParse(value.ToString(), out parsed) ? parsed : 0; } /// The objects in an array field, skipping anything that is not one. private static IEnumerable> Rows(Dictionary row, string key) { object value; if (row == null || !row.TryGetValue(key, out value)) yield break; var list = value as Newtonsoft.Json.Linq.JArray; if (list == null) yield break; foreach (Newtonsoft.Json.Linq.JToken item in list) { var obj = item as Newtonsoft.Json.Linq.JObject; if (obj == null) continue; yield return obj.ToObject>(); } } /// The strings in an array field. private static IEnumerable Strings(Dictionary row, string key) { object value; if (row == null || !row.TryGetValue(key, out value)) yield break; var list = value as Newtonsoft.Json.Linq.JArray; if (list == null) yield break; foreach (Newtonsoft.Json.Linq.JToken item in list) { if (item == null || item.Type == Newtonsoft.Json.Linq.JTokenType.Null) continue; yield return item.ToString(); } } /// /// Permission and group names are compared case-insensitively because both frameworks /// store them lowered. Comparing what the site sent against what the store holds /// without this makes `Kits.VIP` and `kits.vip` two different privileges, one of which /// is granted for ever and never found again. /// private static string Lower(string value) { return value == null ? null : value.Trim().ToLowerInvariant(); } private static HashSet Bucket(Dictionary> map, string key) { HashSet bucket; if (!map.TryGetValue(key, out bucket)) { bucket = new HashSet(); map[key] = bucket; } return bucket; } // ---- diagnostics ---- /// /// The link's own counters, from the server console. This is how an operator answers /// "is the bridge working" without reading a log, and it is the first thing to ask for /// when the website says a server is offline. /// [ConsoleCommand("rg.link")] private void CmdLink(ConsoleSystem.Arg arg) { arg.ReplyWith( $"protocol={ProtocolVersion} serverId={(_config != null ? _config.ServerId : "?")} " + $"connected={_connected} depth={Volatile.Read(ref _depth)} " + $"sent={Interlocked.Read(ref _sent)} dropped={Interlocked.Read(ref _dropped)} " + $"received={Interlocked.Read(ref _received)} connects={Interlocked.Read(ref _connects)} " + $"writeErrors={Interlocked.Read(ref _writeErrors)} bootId={_bootId} " + $"wipeId={(_wipeId ?? "none")}"); } /// /// What the last permission sync did, from the server console. /// /// /// It is the fastest way to tell the two silent failures apart on a rig: a sync that /// reported `unresolved` names granted nothing because no loaded plugin registered /// them, and a sync that reported `pending` members could not put somebody in a group /// because the store has never seen them. Both look like "the website says they have /// it and the game disagrees" from the other end. /// /// [ConsoleCommand("rg.perms")] private void CmdPerms(ConsoleSystem.Arg arg) { string[] groups = permission.GetGroups(); string[] registered = permission.GetPermissions(); arg.ReplyWith( $"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " + $"groups={(groups != null ? groups.Length : 0)} " + $"running={(_permJob != null)}" + Environment.NewLine + $"last sync: {_lastPermSummary}"); } /// /// Where this framework keeps configuration, and what the last write from the website /// did. /// /// /// The root is the interesting half. It is oxide/config on one rig and /// carbon/configs on the other, from this same source file, and an operator /// wondering why the website is offering a tree they do not recognise is usually /// looking at a server whose directories were moved. /// /// [ConsoleCommand("rg.config")] private void CmdConfig(ConsoleSystem.Arg arg) { string root = ConfigRoot(); arg.ReplyWith( $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + $"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine + $"last write: {_lastConfigSummary}"); } /// /// Which of this plugin's hooks have actually fired, and how often. /// /// /// Both frameworks bind hooks by name and arity through reflection, with no /// compile-time check and no warning when a name matches nothing. So a hook Facepunch /// renames on a Thursday, and a hook one framework's catalogue does not list, both /// present identically: silence. This command is the standing answer to both, on /// whichever framework the server is actually running — and it outranks either /// catalogue, because it is a measurement rather than a document. /// /// /// /// A zero is not automatically a fault: OnNewSave fires on wipe day and /// OnUserBanned only when somebody is banned. It is a fault when the thing it /// names has demonstrably happened. /// /// [ConsoleCommand("rg.hooks")] private void CmdHooks(ConsoleSystem.Arg arg) { var fired = new List(); var silent = new List(); foreach (string hook in ExpectedHooks) { long count; _hookCounts.TryGetValue(hook, out count); if (count > 0L) fired.Add($"{hook}={count}"); else silent.Add(hook); } string firedText = fired.Count > 0 ? string.Join(" ", fired.ToArray()) : "(none)"; string silentText = silent.Count > 0 ? string.Join(" ", silent.ToArray()) : "(none)"; arg.ReplyWith( $"protocol={ProtocolVersion} hooks={ExpectedHooks.Length} " + $"fired={fired.Count} silent={silent.Count}" + Environment.NewLine + $"fired: {firedText}" + Environment.NewLine + $"silent: {silentText}"); } } }