All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 20s
lease.list / lease.apply / lease.release and a lease.expired event. The plugin holds the allowlist (decay.scale, 18 populations, the two minimum spawn scalars, and group permissions), the seven-day ceiling (refused, never clamped) and the deadline, which it enforces on its own. Release is compare-and-set. Holds persist in the data directory with their boot: a restart ends a convar hold, while a group-permission hold survives it and is re-armed. EventsEnabled (default off) gates apply only, and a write from the site's config editor now takes effect at once. The permission mirror defers to a leased pair: it is reported as leased, never foreign. Grants pass a null owner: with an owner, Oxide silently refuses any permission another plugin registered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
5996 lines
232 KiB
C#
5996 lines
232 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// The Runic Gateway bridge. Dials out to a rust-link sidecar on loopback and speaks
|
|
/// newline-delimited JSON over it: events out, commands in.
|
|
///
|
|
/// <para>
|
|
/// Threading contract, which everything later in this plugin depends on:
|
|
/// </para>
|
|
/// <list type="bullet">
|
|
/// <item><description>
|
|
/// <see cref="Emit"/> 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.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// One link thread owns the socket. It connects, drains the queue, and reconnects with
|
|
/// backoff. A single writer keeps event ordering intact.
|
|
/// </description></item>
|
|
/// <item><description>
|
|
/// A reader thread parses inbound lines and hands each to the main thread via
|
|
/// <c>Interface.Oxide.NextTick</c>. 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.
|
|
/// </description></item>
|
|
/// </list>
|
|
///
|
|
/// <para>
|
|
/// The outbound queue is bounded. On overflow the oldest record is dropped and counted,
|
|
/// because telemetry is worth less than the server's memory.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <b>Loopback is the trust boundary.</b> There is no token on this link: the plugin and
|
|
/// the sidecar share a host, and the sidecar binds 127.0.0.1. Pointing <c>Host</c> at
|
|
/// anything routable puts an unauthenticated command channel on the network.
|
|
/// </para>
|
|
/// </summary>
|
|
[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
|
|
{
|
|
/// <summary>
|
|
/// Wire-protocol version. Declared in three places that must agree: here,
|
|
/// <c>PROTOCOL_VERSION</c> in the sidecar, and <c>module.json</c> in Module-Rust. The
|
|
/// overlay's <c>overlay.toml</c> carries this same number so the installer can refuse
|
|
/// to pair a sidecar and an overlay that disagree.
|
|
/// </summary>
|
|
private const int ProtocolVersion = 8;
|
|
|
|
/// <summary>
|
|
/// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost
|
|
/// a few seconds of buffering, not half a minute.
|
|
/// </summary>
|
|
private const int MaxBackoffMs = 5000;
|
|
|
|
/// <summary>The longest line accepted from the sidecar. Over-long lines are discarded.</summary>
|
|
private const int MaxInboundLineBytes = 1024 * 1024;
|
|
|
|
/// <summary>
|
|
/// How long to wait for the TCP connect itself before giving up and backing off.
|
|
///
|
|
/// <para>
|
|
/// It exists because <c>TcpClient.Connect</c> 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 <b>drops</b> 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
|
|
/// <c>Unload</c> waits out its whole join on the game's MAIN THREAD.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Generous against any real network and short against a hang. Loopback, which is the
|
|
/// supported deployment, connects in well under a millisecond.
|
|
/// </para>
|
|
/// </summary>
|
|
private const int ConnectTimeoutMs = 3000;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private const float BoardCadenceSeconds = 60f;
|
|
|
|
/// <summary>
|
|
/// The side of one map grid cell, in world units. Rust's own grid, as every community
|
|
/// map and every server console uses it.
|
|
/// </summary>
|
|
private const float GridCellSize = 146.3f;
|
|
|
|
private static readonly ConcurrentQueue<string> Outbound = new ConcurrentQueue<string>();
|
|
private static readonly AutoResetEvent Wake = new AutoResetEvent(false);
|
|
|
|
/// <summary>
|
|
/// Set once, by <c>Unload</c>, and never reset. It is what lets a thread blocked on a
|
|
/// CONNECT give up immediately instead of at the end of its timeout.
|
|
///
|
|
/// <para>
|
|
/// It cannot be <see cref="Wake"/>: that handle also means "the queue has something in
|
|
/// it", which <c>Emit</c> signals constantly, so waiting on it during a connect would
|
|
/// abandon the attempt every time a hook fired. Two meanings, two handles.
|
|
/// </para>
|
|
/// </summary>
|
|
private readonly ManualResetEvent _stopping = new ManualResetEvent(false);
|
|
|
|
private PluginConfig _config;
|
|
|
|
private Thread _link;
|
|
private volatile bool _running;
|
|
private volatile bool _connected;
|
|
|
|
/// <summary>Set when the peer goes away, so the writer stops trying.</summary>
|
|
private volatile bool _dead;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private volatile bool _loggedConnectFailure;
|
|
|
|
/// <summary>
|
|
/// Incremented per connection attempt. A reader from a previous connection must not be
|
|
/// able to mark a newer one dead — <c>reader.Join</c> can time out, and the stale
|
|
/// thread's finally block would otherwise tear down the connection that replaced it.
|
|
/// </summary>
|
|
private int _epoch;
|
|
|
|
private int _depth;
|
|
private long _sent, _dropped, _received, _connects, _writeErrors;
|
|
|
|
/// <summary>
|
|
/// Identifies this run of the <b>server process</b>. 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.
|
|
///
|
|
/// <para>
|
|
/// It must therefore be stable across a <b>plugin</b> reload, which a fresh
|
|
/// <c>Guid</c> at <c>Init</c> is not: <c>oxide.reload</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private string _bootId;
|
|
|
|
/// <summary>
|
|
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, 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.
|
|
///
|
|
/// <para>
|
|
/// Cached rather than read per frame, and re-read on <c>OnNewSave</c>, 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private string _wipeId;
|
|
|
|
/// <summary>
|
|
/// Per-player counters waiting to be flushed as a <c>player.tally</c> 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.
|
|
/// </summary>
|
|
private readonly Dictionary<ulong, Tally> _tallies = new Dictionary<ulong, Tally>();
|
|
|
|
/// <summary>
|
|
/// When each connected player connected, in epoch milliseconds, so a disconnect can
|
|
/// report a session length. Main thread only.
|
|
///
|
|
/// <para>
|
|
/// A player already on the server when this plugin loaded has no entry, and
|
|
/// <c>sessionSec</c> is then <b>absent</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private readonly Dictionary<ulong, long> _connectedAt = new Dictionary<ulong, long>();
|
|
|
|
/// <summary>
|
|
/// Every hook name this plugin declares, and whether it has fired at least once.
|
|
///
|
|
/// <para>
|
|
/// Both frameworks bind hooks by <b>name and arity, through reflection, with no
|
|
/// compile-time check</b>: 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. <c>rg.hooks</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private readonly Dictionary<string, long> _hookCounts = new Dictionary<string, long>();
|
|
|
|
/// <summary>
|
|
/// The repeating boards/tally timer.
|
|
///
|
|
/// <para>
|
|
/// Typed as the plugin-facing <c>Oxide.Plugins.Timer</c>, which is what
|
|
/// <c>timer.Every</c> hands back — <b>not</b> <c>Oxide.Core.Libraries.Timer.TimerInstance</c>,
|
|
/// 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
|
|
/// <c>Unload</c> stop it before the link thread is joined rather than after.
|
|
/// </para>
|
|
/// </summary>
|
|
private Timer _cadence;
|
|
|
|
// ---- lifecycle ----
|
|
|
|
/// <summary>
|
|
/// Every hook this plugin declares. Seeded into <c>_hookCounts</c> at zero so that
|
|
/// <c>rg.hooks</c> can report a hook that has <b>never</b> fired — which is the whole
|
|
/// question, and which a dictionary that only learns names as they arrive can never
|
|
/// answer.
|
|
/// </summary>
|
|
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",
|
|
"OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded",
|
|
"OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged"
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
if (_clansBoardSoon != null)
|
|
{
|
|
_clansBoardSoon.Destroy();
|
|
_clansBoardSoon = 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;
|
|
|
|
// Holds are NOT given back on unload. The file keeps them, and the next load re-arms
|
|
// them: a plugin reload is not the end of an event (§27.3).
|
|
StopLeaseTimer();
|
|
|
|
_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
|
|
{
|
|
/// <summary>Where the sidecar listens. Loopback is the trust boundary; see the class docs.</summary>
|
|
public string Host = "127.0.0.1";
|
|
|
|
public int Port = 7799;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public int QueueCap = 5000;
|
|
|
|
/// <summary>
|
|
/// This server's stable identity across wipes and restarts, as the website knows
|
|
/// it. It is <b>not</b> derived from the hostname: an operator renames a server for
|
|
/// a season and the site must not lose its history for it.
|
|
/// </summary>
|
|
public string ServerId = "main";
|
|
|
|
/// <summary>
|
|
/// Whether the website may borrow values on this server for events (protocol 8,
|
|
/// D76). <b>Off until an operator turns it on.</b> Letting staff moderate from a
|
|
/// screen they are looking at, and letting a schedule change the world at four in
|
|
/// the morning, are different consents, and one switch cannot honestly express
|
|
/// both. It gates taking a lease only: listing and giving back always work, so
|
|
/// turning it off never strands a value somebody already borrowed.
|
|
/// </summary>
|
|
public bool EventsEnabled = false;
|
|
}
|
|
|
|
protected override void LoadDefaultConfig()
|
|
{
|
|
Config.WriteObject(new PluginConfig(), true);
|
|
}
|
|
|
|
private void LoadConfigValues()
|
|
{
|
|
_config = Config.ReadObject<PluginConfig>() ?? 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";
|
|
|
|
// A config written before protocol 8 has no `EventsEnabled`, and a key that is not in
|
|
// the file is a key the website's config editor cannot show an operator. Written back
|
|
// once, with its default, so the switch is visible before anybody needs it.
|
|
if (Config["EventsEnabled"] == null)
|
|
Config.WriteObject(_config, true);
|
|
}
|
|
|
|
private void Loaded()
|
|
{
|
|
LoadConfigValues();
|
|
Puts($"protocol {ProtocolVersion}, serverId '{_config.ServerId}', sidecar {_config.Host}:{_config.Port}");
|
|
|
|
// Here rather than at `OnServerInitialized`: the link thread starts in `Init`, so a
|
|
// lease command can arrive before the world has finished loading, and it must find
|
|
// the holds already in place rather than have them loaded over it afterwards.
|
|
LoadLeases();
|
|
}
|
|
|
|
// ---- the outbound queue ----
|
|
|
|
/// <summary>
|
|
/// Main thread. Non-blocking. <paramref name="line"/> must already be a complete JSON
|
|
/// object with no embedded newline; the newline is appended by the writer as the frame
|
|
/// delimiter.
|
|
/// </summary>
|
|
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<string, object> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Connects with a bound, and with a way out.
|
|
///
|
|
/// <para>
|
|
/// The wait ends on whichever comes first: the connect completing, the plugin being
|
|
/// unloaded, or <see cref="ConnectTimeoutMs"/>. Without the middle one a reload against
|
|
/// an unreachable sidecar costs the game's main thread the whole join timeout —
|
|
/// observed as <c>hook 'Unload' took longer than 100ms [2002ms]</c> beside
|
|
/// <c>link thread did not stop cleanly</c>, which is phase 1's stall arriving by a
|
|
/// different road.
|
|
/// </para>
|
|
/// </summary>
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reader thread. Marshals to the main thread; nothing that touches the game happens
|
|
/// here.
|
|
/// </summary>
|
|
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 ----
|
|
|
|
/// <summary>Main thread. Every world read below is safe only because of that.</summary>
|
|
private void HandleCommand(string line)
|
|
{
|
|
Dictionary<string, object> cmd;
|
|
|
|
try
|
|
{
|
|
cmd = JsonConvert.DeserializeObject<Dictionary<string, object>>(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<string, object> 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;
|
|
|
|
case "lease.list":
|
|
// Protocol 8 (PLAN.md §27). What this server lends, what it holds now, and
|
|
// every hold in force — one read serving the website's `read()` and
|
|
// `inForce()` alike.
|
|
HandleLeaseList(cmd, reqId);
|
|
break;
|
|
|
|
case "lease.apply":
|
|
HandleLeaseApply(cmd, reqId);
|
|
break;
|
|
|
|
case "lease.release":
|
|
HandleLeaseRelease(cmd, reqId);
|
|
break;
|
|
|
|
default:
|
|
Puts($"unknown command '{verb}'");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// ---- frames ----
|
|
|
|
/// <summary>
|
|
/// Builds the envelope every outbound frame shares (PROTOCOL.md §8.1). Nothing may
|
|
/// emit a frame it did not get from here.
|
|
///
|
|
/// <para>
|
|
/// <paramref name="type"/> is what the sidecar files on — <c>event</c> is appended to
|
|
/// history, <c>snapshot</c> replaces a board, <c>reply</c> is routed by
|
|
/// <c>reqId</c>, <c>control</c> 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// <c>wipeId</c> is <b>omitted</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private Dictionary<string, object> Frame(string kind, string type)
|
|
{
|
|
var frame = new Dictionary<string, object>
|
|
{
|
|
{ "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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Both boards, in the order a reader wants them: what the server is,
|
|
/// then who is on it.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void SendBoards()
|
|
{
|
|
SendHello();
|
|
SendPlayersBoard();
|
|
|
|
// Last, and fenced: the clan board reads the game's clan database, which is the one
|
|
// board here built from something other than a list in memory. A failure there must
|
|
// cost the clans and nothing else — the two boards above are what every page renders.
|
|
try
|
|
{
|
|
SendClansBoard();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Puts($"could not build the clans board: {Flatten(ex.Message)}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Main thread.</summary>
|
|
private void SendHello()
|
|
{
|
|
Dictionary<string, object> hello = Frame("server.hello", "snapshot");
|
|
|
|
Merge(hello, ServerBody());
|
|
|
|
hello["protocol"] = ProtocolVersion;
|
|
hello["bootId"] = _bootId;
|
|
hello["plugin"] = Version.ToString();
|
|
|
|
EmitFrame(hello);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void SendPlayersBoard()
|
|
{
|
|
var players = new List<object>();
|
|
|
|
if (BasePlayer.activePlayerList != null)
|
|
{
|
|
foreach (BasePlayer player in BasePlayer.activePlayerList)
|
|
{
|
|
if (player == null)
|
|
continue;
|
|
|
|
var row = new Dictionary<string, object>
|
|
{
|
|
{ "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<string, object> frame = Frame("players.online", "snapshot");
|
|
frame["count"] = players.Count;
|
|
frame["players"] = players;
|
|
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. The shared body of <c>server.hello</c> and <c>server.status</c> — 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.
|
|
/// </summary>
|
|
private Dictionary<string, object> ServerBody()
|
|
{
|
|
var status = new Dictionary<string, object>
|
|
{
|
|
{ "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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts an IP address onto a frame, or leaves it off.
|
|
///
|
|
/// <para>
|
|
/// The game does not always have one. A console <c>banid</c> against an offline id
|
|
/// produces the literal string <b><c>"0"</c></b> 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Absent, not zero. The same rule `saveCreatedAt` and `sessionSec` follow.
|
|
/// </para>
|
|
/// </summary>
|
|
private static void PutAddress(Dictionary<string, object> frame, string ip)
|
|
{
|
|
if (string.IsNullOrEmpty(ip) || ip == "0" || ip == "0.0.0.0")
|
|
return;
|
|
|
|
frame["ip"] = ip;
|
|
}
|
|
|
|
private static void Merge(Dictionary<string, object> into, Dictionary<string, object> from)
|
|
{
|
|
foreach (KeyValuePair<string, object> pair in from)
|
|
into[pair.Key] = pair.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Describes who or what killed somebody, onto <paramref name="frame"/>.
|
|
///
|
|
/// <para>
|
|
/// Four outcomes, and collapsing any two of them loses something a killfeed needs:
|
|
/// <c>player</c> (a real kill, and the only one that belongs on a leaderboard),
|
|
/// <c>self</c>, <c>npc</c> and <c>environment</c> — 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private static void DescribeAttacker(
|
|
Dictionary<string, object> 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 ----
|
|
|
|
/// <summary>
|
|
/// An entity's position as Rust's own map reference (<c>H7</c>), 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.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>A column index as Rust spells it: 0 is A, 25 is Z, 26 is AA.</summary>
|
|
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 ----
|
|
|
|
/// <summary>
|
|
/// The current wipe, as <c>w-yyyyMMddTHHmmssZ</c>, or null if this server has never
|
|
/// saved.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records that a hook fired. See <c>_hookCounts</c> for why this exists at all.
|
|
/// </summary>
|
|
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<string, object> frame = Frame("server.initialized", "event");
|
|
frame["initial"] = initial;
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
private void OnServerShutdown()
|
|
{
|
|
MarkHook("OnServerShutdown");
|
|
FlushTallies();
|
|
EmitFrame(Frame("server.shutdown", "event"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<string, object> 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<string, object> 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<string, object> 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<string, object> frame = Frame("player.respawned", "event");
|
|
frame["steamId"] = player.UserIDString;
|
|
frame["name"] = player.displayName;
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Void, and deliberately: uMod documents a non-null return here as overriding the
|
|
/// death itself. See rule 1 at the top of this region.
|
|
/// </summary>
|
|
private void OnPlayerDeath(BasePlayer player, HitInfo info)
|
|
{
|
|
MarkHook("OnPlayerDeath");
|
|
|
|
if (player == null || player.IsNpc)
|
|
return;
|
|
|
|
Dictionary<string, object> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Void, and deliberately: a non-null return here overrides chat delivery — the bridge
|
|
/// would be silently eating messages.
|
|
/// </summary>
|
|
private void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel)
|
|
{
|
|
MarkHook("OnPlayerChat");
|
|
|
|
if (player == null || string.IsNullOrEmpty(message))
|
|
return;
|
|
|
|
Dictionary<string, object> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Void, and deliberately: a non-null return here replaces what the player receives.
|
|
/// This hook is a counter — see rule 2.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>
|
|
/// The type checks are ordered cheapest-first and return early, because this hook
|
|
/// fires for every tree, barrel and animal on the map.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
// Protocol 7 (PROTOCOL.md §13): a raid is the loss of anything a base is made of, not
|
|
// only a building block. A door is NOT a BuildingBlock — both derive from
|
|
// StabilityEntity as siblings — so until protocol 7 the most common raid there is, two
|
|
// charges on a door, reached nobody.
|
|
string structure = StructureKind(entity);
|
|
|
|
// `OwnerID == 0` is the world's own: a monument's doors and walls. Destroying those is
|
|
// not a raid on anybody, and emitting them would fill the staff feed with loot runs.
|
|
if (structure == null || entity.OwnerID == 0UL)
|
|
return;
|
|
|
|
// The tally's meaning is unchanged since protocol 2 — building blocks only — so a
|
|
// leaderboard column does not quietly change what it counts in the middle of a wipe.
|
|
if (structure == "block")
|
|
TallyFor(attacker).Structures++;
|
|
|
|
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
|
|
frame["ownerId"] = entity.OwnerID.ToString();
|
|
frame["prefab"] = entity.ShortPrefabName;
|
|
frame["structure"] = structure;
|
|
|
|
// From `userID`, not `UserIDString`. The string is filled in when a player connects,
|
|
// loads as a sleeper, or is an engine bot — and at no other time, so a player another
|
|
// plugin spawned carries none. The website skips the raid alert when the attacker is
|
|
// authorised on the cupboard (PLAN.md D59), and a null here would quietly defeat that.
|
|
frame["attackerId"] = ((ulong)attacker.userID).ToString();
|
|
frame["attackerName"] = attacker.displayName;
|
|
|
|
string grid = GridOf(entity);
|
|
if (grid != null)
|
|
frame["grid"] = grid;
|
|
|
|
AddAuthorization(frame, entity);
|
|
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The most people one <c>entity.destroyed</c> frame names as authorised. A cupboard
|
|
/// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game
|
|
/// link; 64 is several full teams, and a frame cut short says so.
|
|
/// </summary>
|
|
private const int MaxAuthorizedOnFrame = 64;
|
|
|
|
/// <summary>
|
|
/// Which of the four things a base is made of this entity is, or null for anything else.
|
|
/// Ordered by how often each dies, which is also cheapest-first.
|
|
/// </summary>
|
|
private static string StructureKind(BaseEntity entity)
|
|
{
|
|
if (entity is BuildingBlock)
|
|
return "block";
|
|
|
|
// External gates are Doors too, which is right: a gate is a door in a wall.
|
|
if (entity is Door)
|
|
return "door";
|
|
|
|
if (entity is SimpleBuildingBlock)
|
|
return "wall";
|
|
|
|
if (entity is BuildingPrivlidge)
|
|
return "cupboard";
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Who lives here, by the game's own definition: everyone authorised on the building's
|
|
/// tool cupboard. Both fields are ABSENT when there is no cupboard, which is a different
|
|
/// answer from an empty list — "nobody is authorised" and "there is nothing to be
|
|
/// authorised on" — and the module alerts nobody in either case (PLAN.md D67).
|
|
///
|
|
/// <para>
|
|
/// <c>authorizedPlayers</c> is the set, and nothing else is. The cupboard also holds
|
|
/// <c>recentGroupMembers</c>, which LOOKS like residents and is not: it counts code-lock
|
|
/// users towards group upkeep and prunes itself on a timer.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The lookup resolves through the entity's building, which is cheap. An external wall
|
|
/// has no building and falls back to a sphere query on this thread — acceptable because
|
|
/// it runs only after every cheap filter above has passed, at the rate walls fall rather
|
|
/// than the rate this hook fires.
|
|
/// </para>
|
|
/// </summary>
|
|
private static void AddAuthorization(Dictionary<string, object> frame, BaseEntity entity)
|
|
{
|
|
// A cupboard being destroyed still holds its list: OnEntityDeath runs before the kill.
|
|
BuildingPrivlidge cupboard = entity as BuildingPrivlidge ?? entity.GetBuildingPrivilege();
|
|
|
|
if (cupboard == null || cupboard.net == null || cupboard.authorizedPlayers == null)
|
|
return;
|
|
|
|
HashSet<ulong> online = new HashSet<ulong>();
|
|
|
|
if (BasePlayer.activePlayerList != null)
|
|
{
|
|
foreach (BasePlayer player in BasePlayer.activePlayerList)
|
|
{
|
|
if (player != null)
|
|
online.Add(player.userID);
|
|
}
|
|
}
|
|
|
|
List<object> authorized = new List<object>();
|
|
|
|
foreach (ulong steamId in cupboard.authorizedPlayers)
|
|
{
|
|
if (authorized.Count >= MaxAuthorizedOnFrame)
|
|
{
|
|
frame["authorizedTruncated"] = true;
|
|
break;
|
|
}
|
|
|
|
authorized.Add(new Dictionary<string, object>
|
|
{
|
|
{ "steamId", steamId.ToString() },
|
|
{ "online", online.Contains(steamId) },
|
|
});
|
|
}
|
|
|
|
frame["buildingId"] = cupboard.net.ID.Value.ToString();
|
|
frame["authorized"] = authorized;
|
|
}
|
|
|
|
private void OnPlayerReported(
|
|
BasePlayer reporter, string targetName, string targetId,
|
|
string subject, string message, string type)
|
|
{
|
|
MarkHook("OnPlayerReported");
|
|
|
|
Dictionary<string, object> 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<string, object> 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<string, object> frame = Frame("player.unbanned", "event");
|
|
frame["steamId"] = id;
|
|
frame["name"] = name;
|
|
PutAddress(frame, ipAddress);
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
///
|
|
/// <para>
|
|
/// It fires on every connection attempt, approved or not — so it is an attempt, not a
|
|
/// denial. uMod publishes no <c>OnUserRejected</c>, 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 <c>player.approved</c>, decided by whoever reads the two
|
|
/// frames later; PROTOCOL.md §8.8 records that as a correction to the trigger
|
|
/// catalogue.
|
|
/// </para>
|
|
/// </summary>
|
|
private void CanUserLogin(string name, string id, string ipAddress)
|
|
{
|
|
MarkHook("CanUserLogin");
|
|
|
|
Dictionary<string, object> 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<string, object> frame = Frame("player.approved", "event");
|
|
frame["steamId"] = id;
|
|
frame["name"] = name;
|
|
PutAddress(frame, ipAddress);
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
// ---- clans (protocol 6) ----
|
|
//
|
|
// Rust's FIRST-PARTY clan system, which the website turns into core's Teams (R5,
|
|
// PROTOCOL.md §12). Not the uMod Clans plugin: that is a separate system that never
|
|
// touches `ClanManager` (D47), and this region reads nothing of it except whether it is
|
|
// loaded, so the website can warn that its clans are not Teams.
|
|
//
|
|
// One board and five events, the same split as presence. The events carry the story —
|
|
// founded, joined, left, removed, disbanded — and the board is the reconciliation point
|
|
// that corrects anything missed within a minute. Leadership travels ONLY on the board,
|
|
// because the game raises no hook when somebody is promoted or demoted (§12.3).
|
|
//
|
|
// Three facts about the game shape everything below, and all three were read from its
|
|
// assemblies rather than from a catalogue:
|
|
//
|
|
// 1. **There is no "list every clan" call.** The backend offers get-by-id and
|
|
// get-by-member; the only listing is the clan leaderboard, and the game clamps that
|
|
// to 100 clans by score. That clamp is the board's ceiling (D55), and a board at it
|
|
// says `truncated` rather than pretending to be the whole answer.
|
|
//
|
|
// 2. **A clan id is not an identity.** The database file's version number is
|
|
// hard-coded, so a game update that bumps it starts a fresh file whose ids restart at
|
|
// 1. Every frame therefore carries the clan's creation time beside its id, and the
|
|
// pair is what the website keys a Team on (D52).
|
|
//
|
|
// 3. **The local backend is synchronous behind an async signature.** Every `ValueTask`
|
|
// it returns has already completed, which is what makes reading it from a hook
|
|
// legal. A task that has NOT completed means some other backend, and it is refused
|
|
// rather than waited on — waiting would be the main thread blocking on a database.
|
|
|
|
/// <summary>
|
|
/// The most clans one board can carry, and it is the GAME's number: the clan leaderboard
|
|
/// clamps its limit to 10..100. Asking for more returns 100.
|
|
/// </summary>
|
|
private const int MaxClansOnBoard = 100;
|
|
|
|
/// <summary>
|
|
/// How many bytes of clan rows one board may carry. Well inside the sidecar's 1 MiB line
|
|
/// cap, which discards an over-long line outright: a board that silently never arrived
|
|
/// would look like a server with no clans. 100 clans of 100 members each is about a
|
|
/// megabyte, so the cap is reachable, and a board that meets it says so.
|
|
/// </summary>
|
|
private const int ClanBoardBudgetBytes = 768 * 1024;
|
|
|
|
/// <summary>
|
|
/// How long after a membership change the clan board is re-sent. The event reaches the
|
|
/// sidecar at once; the roster that follows it waits for the game to refresh its own
|
|
/// cached clan, which `OnClanMemberAdded` fires before.
|
|
/// </summary>
|
|
private const float ClanBoardSettleSeconds = 3f;
|
|
|
|
/// <summary>A pending early re-send of the clan board, so a burst of changes sends one.</summary>
|
|
private Timer _clansBoardSoon;
|
|
|
|
/// <summary>
|
|
/// Main thread. Every clan on this server, with its roster and roles, as the board the
|
|
/// website reconciles Teams against.
|
|
///
|
|
/// <para>
|
|
/// The board is ALWAYS sent, including when there is nothing to read — with
|
|
/// <c>supported: false</c> and a reason. Saying nothing would leave the website holding
|
|
/// the last board it saw and no way to know it had stopped being true.
|
|
/// </para>
|
|
/// </summary>
|
|
private void SendClansBoard()
|
|
{
|
|
Dictionary<string, object> frame = Frame("clans", "snapshot");
|
|
var clans = new List<object>();
|
|
|
|
frame["enabled"] = ConVar.Clan.enabled;
|
|
frame["umodClans"] = plugins.Find("Clans") != null;
|
|
frame["clans"] = clans;
|
|
frame["count"] = 0;
|
|
frame["truncated"] = false;
|
|
|
|
// Switched off by the operator. That is an authoritative answer — this server has no
|
|
// clans — and different from "could not read them", so it is supported and empty.
|
|
if (!ConVar.Clan.enabled)
|
|
{
|
|
frame["supported"] = true;
|
|
EmitFrame(frame);
|
|
return;
|
|
}
|
|
|
|
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
|
|
|
|
if (backend == null)
|
|
{
|
|
frame["supported"] = false;
|
|
frame["reason"] = "the game's clan backend has not started yet";
|
|
EmitFrame(frame);
|
|
return;
|
|
}
|
|
|
|
frame["backend"] = backend.GetType().Name;
|
|
|
|
// A Nexus server keeps its clans on Facepunch's side of the Nexus, behind a backend
|
|
// whose calls do not complete on the spot. Refused with a reason rather than guessed
|
|
// at (D55): half-reading it would be a board that is wrong in a way nobody can see.
|
|
if (!(backend is LocalClanBackend))
|
|
{
|
|
frame["supported"] = false;
|
|
frame["reason"] = $"this server's clans are held by a {backend.GetType().Name}, which the bridge does not read";
|
|
EmitFrame(frame);
|
|
return;
|
|
}
|
|
|
|
System.Threading.Tasks.ValueTask<ClanValueResult<List<ClanLeaderboardEntry>>> listing = backend.GetLeaderboard(MaxClansOnBoard);
|
|
|
|
if (!listing.IsCompleted || !listing.Result.IsSuccess)
|
|
{
|
|
frame["supported"] = false;
|
|
frame["reason"] = listing.IsCompleted
|
|
? $"the game's clan list could not be read ({listing.Result.Result})"
|
|
: "the game's clan list did not answer at once";
|
|
EmitFrame(frame);
|
|
return;
|
|
}
|
|
|
|
List<ClanLeaderboardEntry> entries = listing.Result.Value;
|
|
|
|
// At the clamp there may be more clans than the game will list. It cannot be told
|
|
// apart from exactly 100, so it is reported as possibly more — the honest direction,
|
|
// since the website then only ADDS Teams and never removes one on this answer.
|
|
bool truncated = entries.Count >= MaxClansOnBoard;
|
|
int bytes = 0;
|
|
|
|
foreach (ClanLeaderboardEntry entry in entries)
|
|
{
|
|
IClan clan = ReadClan(backend, entry.ClanId);
|
|
|
|
if (clan == null)
|
|
continue;
|
|
|
|
Dictionary<string, object> row = DescribeClan(clan);
|
|
int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None));
|
|
|
|
if (bytes + size > ClanBoardBudgetBytes)
|
|
{
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
bytes += size;
|
|
clans.Add(row);
|
|
}
|
|
|
|
frame["supported"] = true;
|
|
frame["truncated"] = truncated;
|
|
frame["count"] = clans.Count;
|
|
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. One clan by id, from the game's cache if it is there and from its
|
|
/// database if not, or null. Never waits: see fact 3 at the top of this region.
|
|
/// </summary>
|
|
private static IClan ReadClan(IClanBackend backend, long clanId)
|
|
{
|
|
IClan clan;
|
|
|
|
if (backend.TryGet(clanId, out clan) && clan != null)
|
|
return clan;
|
|
|
|
System.Threading.Tasks.ValueTask<ClanValueResult<IClan>> read = backend.Get(clanId);
|
|
|
|
if (!read.IsCompleted || !read.Result.IsSuccess)
|
|
return null;
|
|
|
|
return read.Result.Value;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. A clan as the board carries it.
|
|
///
|
|
/// <para>
|
|
/// A member's <c>LastSeen</c> is left off on purpose. It says when somebody was last on
|
|
/// the server, which is presence, and the website's rule is that nothing names who is on
|
|
/// by default (PLAN.md §23). Not sending it is simpler than trusting every reader to
|
|
/// withhold it.
|
|
/// </para>
|
|
/// </summary>
|
|
private Dictionary<string, object> DescribeClan(IClan clan)
|
|
{
|
|
var roles = new Dictionary<int, ClanRole>();
|
|
|
|
if (clan.Roles != null)
|
|
{
|
|
foreach (ClanRole role in clan.Roles)
|
|
roles[role.RoleId] = role;
|
|
}
|
|
|
|
var members = new List<object>();
|
|
|
|
if (clan.Members != null)
|
|
{
|
|
foreach (ClanMember member in clan.Members)
|
|
{
|
|
var row = new Dictionary<string, object>
|
|
{
|
|
{ "steamId", member.SteamId.ToString() },
|
|
{ "joinedMs", member.Joined }
|
|
};
|
|
|
|
ClanRole role;
|
|
|
|
// A member whose role id matches no role is left without a rank rather than
|
|
// given one: "not known" must never read as "leads this clan", and rank 1 is
|
|
// exactly what a default of zero-plus-one would produce.
|
|
if (roles.TryGetValue(member.RoleId, out role))
|
|
{
|
|
row["rank"] = role.Rank;
|
|
row["role"] = role.Name;
|
|
}
|
|
|
|
string name = PlayerName(member.SteamId);
|
|
if (!string.IsNullOrEmpty(name))
|
|
row["name"] = name;
|
|
|
|
members.Add(row);
|
|
}
|
|
}
|
|
|
|
UnityEngine.Color32 color = clan.Color;
|
|
|
|
return new Dictionary<string, object>
|
|
{
|
|
{ "clanId", clan.ClanId },
|
|
{ "createdMs", clan.Created },
|
|
{ "name", clan.Name },
|
|
{ "color", $"#{color.r:x2}{color.g:x2}{color.b:x2}" },
|
|
{ "score", clan.Score },
|
|
{ "maxMembers", clan.MaxMemberCount },
|
|
{ "members", members }
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. A player's name, whether or not they are on, or null. The framework
|
|
/// remembers everybody who has ever joined, which is what lets an offline clan member
|
|
/// have a name on a roster.
|
|
/// </summary>
|
|
private string PlayerName(ulong steamId)
|
|
{
|
|
BasePlayer player = BasePlayer.FindByID(steamId) ?? BasePlayer.FindSleeping(steamId);
|
|
|
|
if (player != null)
|
|
return player.displayName;
|
|
|
|
Oxide.Core.Libraries.Covalence.IPlayer known = covalence.Players.FindPlayerById(steamId.ToString());
|
|
return known != null ? known.Name : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. The envelope every clan event shares: which clan, identified the way
|
|
/// the website keys it (D52), and its name so a feed line can say something without a
|
|
/// lookup.
|
|
/// </summary>
|
|
private Dictionary<string, object> ClanFrame(string kind, IClan clan)
|
|
{
|
|
Dictionary<string, object> frame = Frame(kind, "event");
|
|
frame["clanId"] = clan.ClanId;
|
|
frame["createdMs"] = clan.Created;
|
|
frame["clanName"] = clan.Name;
|
|
return frame;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Puts a player onto a clan event as <c>steamId</c> and <c>name</c> — the
|
|
/// same two fields every player event carries, so the website's player table learns the
|
|
/// name the same way it does from a connect.
|
|
/// </summary>
|
|
private void PutPlayer(Dictionary<string, object> frame, string idKey, string nameKey, ulong steamId)
|
|
{
|
|
if (steamId == 0UL)
|
|
return;
|
|
|
|
frame[idKey] = steamId.ToString();
|
|
|
|
string name = PlayerName(steamId);
|
|
if (!string.IsNullOrEmpty(name))
|
|
frame[nameKey] = name;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Re-sends the clan board shortly, once, however many changes arrive in
|
|
/// the meantime. The cadence would get there within a minute anyway; this is what makes
|
|
/// a roster follow the event that changed it.
|
|
/// </summary>
|
|
private void ScheduleClansBoard()
|
|
{
|
|
if (_clansBoardSoon != null)
|
|
return;
|
|
|
|
_clansBoardSoon = timer.Once(ClanBoardSettleSeconds, () =>
|
|
{
|
|
_clansBoardSoon = null;
|
|
|
|
if (!_connected)
|
|
return;
|
|
|
|
try
|
|
{
|
|
SendClansBoard();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Puts($"could not build the clans board: {Flatten(ex.Message)}");
|
|
}
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The founder's own membership raises no <c>OnClanMemberAdded</c> — the game adds them
|
|
/// inside the creation — so this event carries them, and the website counts them in.
|
|
/// </summary>
|
|
private void OnClanCreated(LocalClan clan, ulong steamId)
|
|
{
|
|
MarkHook("OnClanCreated");
|
|
|
|
if (clan == null)
|
|
return;
|
|
|
|
Dictionary<string, object> frame = ClanFrame("clan.created", clan);
|
|
PutPlayer(frame, "steamId", "name", steamId);
|
|
EmitFrame(frame);
|
|
|
|
ScheduleClansBoard();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Typed exactly, and that matters here more than anywhere: the uMod Clans plugin raises
|
|
/// a hook with this SAME NAME and arity — <c>OnClanDisbanded(string, List<ulong>)</c>
|
|
/// — about its own, unrelated clans. Both frameworks match a call to a method by its
|
|
/// argument types, so a loosely typed signature here would file the plugin's clans as
|
|
/// the game's.
|
|
/// </summary>
|
|
private void OnClanDisbanded(LocalClan clan, ulong steamId)
|
|
{
|
|
MarkHook("OnClanDisbanded");
|
|
|
|
if (clan == null)
|
|
return;
|
|
|
|
Dictionary<string, object> frame = ClanFrame("clan.disbanded", clan);
|
|
PutPlayer(frame, "steamId", "name", steamId);
|
|
|
|
// Protocol 7: who was in it. The website tells a disbanded clan's members, and by the
|
|
// time it reads this frame the next board may already have removed the roster it would
|
|
// otherwise ask — the board is re-sent seconds after this, and after an outage it is
|
|
// applied before the backlog. The game has deleted the clan from its database by now,
|
|
// and walked `Members` to drop each one's membership, but it never empties the list
|
|
// itself — so it is still whole here, bounded by the clan's own member limit.
|
|
if (clan.Members != null)
|
|
{
|
|
List<object> members = new List<object>();
|
|
|
|
foreach (ClanMember member in clan.Members)
|
|
members.Add(member.SteamId.ToString());
|
|
|
|
frame["members"] = members;
|
|
}
|
|
|
|
EmitFrame(frame);
|
|
|
|
ScheduleClansBoard();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one clan hook that hands over a bare id rather than a clan: the game raises it
|
|
/// from inside its database layer, before the cached clan has been refreshed. The id is
|
|
/// enough to find the clan and its creation time; if even that fails, the frame goes
|
|
/// without <c>createdMs</c> and the website matches it on the id alone.
|
|
/// </summary>
|
|
private void OnClanMemberAdded(long clanId, ulong steamId)
|
|
{
|
|
MarkHook("OnClanMemberAdded");
|
|
|
|
IClanBackend backend = ClanManager.ServerInstance != null ? ClanManager.ServerInstance.Backend : null;
|
|
IClan clan = backend != null ? ReadClan(backend, clanId) : null;
|
|
|
|
Dictionary<string, object> frame;
|
|
|
|
if (clan != null)
|
|
{
|
|
frame = ClanFrame("clan.member.added", clan);
|
|
}
|
|
else
|
|
{
|
|
frame = Frame("clan.member.added", "event");
|
|
frame["clanId"] = clanId;
|
|
}
|
|
|
|
PutPlayer(frame, "steamId", "name", steamId);
|
|
EmitFrame(frame);
|
|
|
|
ScheduleClansBoard();
|
|
}
|
|
|
|
private void OnClanMemberLeft(LocalClan clan, ulong steamId)
|
|
{
|
|
MarkHook("OnClanMemberLeft");
|
|
|
|
if (clan == null)
|
|
return;
|
|
|
|
Dictionary<string, object> frame = ClanFrame("clan.member.left", clan);
|
|
PutPlayer(frame, "steamId", "name", steamId);
|
|
EmitFrame(frame);
|
|
|
|
ScheduleClansBoard();
|
|
}
|
|
|
|
private void OnClanMemberKicked(LocalClan clan, ulong steamId, ulong bySteamId)
|
|
{
|
|
MarkHook("OnClanMemberKicked");
|
|
|
|
if (clan == null)
|
|
return;
|
|
|
|
Dictionary<string, object> frame = ClanFrame("clan.member.kicked", clan);
|
|
PutPlayer(frame, "steamId", "name", steamId);
|
|
PutPlayer(frame, "bySteamId", "byName", bySteamId);
|
|
EmitFrame(frame);
|
|
|
|
ScheduleClansBoard();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Not an event: a colour is a property of the clan, so it travels on the board. The
|
|
/// hook only brings the next board forward.
|
|
/// </summary>
|
|
private void OnClanColorChanged(LocalClan clan, UnityEngine.Color32 color, ulong steamId)
|
|
{
|
|
MarkHook("OnClanColorChanged");
|
|
ScheduleClansBoard();
|
|
}
|
|
|
|
// ---- tallies ----
|
|
|
|
/// <summary>
|
|
/// One player's counters between two flushes. Main thread only; no lock, ever.
|
|
/// </summary>
|
|
private class Tally
|
|
{
|
|
public readonly Dictionary<string, int> Gathered = new Dictionary<string, int>();
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Emits one <c>player.tally</c> per player with anything to report, and clears what it
|
|
/// emitted.
|
|
///
|
|
/// <para>
|
|
/// A tally frame is a <b>delta, not a running total</b>: 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).
|
|
/// </para>
|
|
/// </summary>
|
|
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<ulong>(_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<string, object> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The one repeating job: re-send the boards and flush the tallies.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void Cadence()
|
|
{
|
|
if (!_connected)
|
|
return;
|
|
|
|
FlushTallies();
|
|
SendBoards();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collapses every run of whitespace <b>and control characters</b> to one space, so an
|
|
/// exception message stays one log entry.
|
|
///
|
|
/// <para>
|
|
/// The control half is not defensive padding. Mono's <c>SocketException.Message</c> on
|
|
/// Windows comes back from a fixed-size OS buffer and is <b>NUL-padded</b> — a couple
|
|
/// of hundred <c>\0</c> bytes sitting in the middle of the sentence. <c>\0</c> is not
|
|
/// whitespace, so <c>Trim</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// The server process's start instant, as <c>boot-yyyyMMddTHHmmssZ</c>.
|
|
///
|
|
/// <para>
|
|
/// <c>Process.StartTime</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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 ----
|
|
|
|
/// <summary>
|
|
/// The alphabet a link code is drawn from.
|
|
///
|
|
/// <para>
|
|
/// <b>No O, 0, I or 1.</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
|
|
private const int CodeLength = 6;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static readonly TimeSpan CodeTtl = TimeSpan.FromMinutes(5);
|
|
|
|
/// <summary>How long a player must wait between <c>/link</c> requests.</summary>
|
|
private static readonly TimeSpan RequestCooldown = TimeSpan.FromSeconds(30);
|
|
|
|
/// <summary>
|
|
/// An unconfirmed code, and the Steam identity it will bind.
|
|
///
|
|
/// <para>
|
|
/// <b>These live in memory and nowhere else</b>, 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 <c>/link</c> again, which is
|
|
/// cheaper than a credential in a second process.
|
|
/// </para>
|
|
/// </summary>
|
|
private sealed class PendingLink
|
|
{
|
|
public string SteamId;
|
|
public string Name;
|
|
public DateTime Expires;
|
|
}
|
|
|
|
/// <summary>code -> pending link. Main thread only, like every world read here.</summary>
|
|
private readonly Dictionary<string, PendingLink> _codes =
|
|
new Dictionary<string, PendingLink>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
/// <summary>steam id -> when they last asked, so <c>/link</c> cannot be spammed.</summary>
|
|
private readonly Dictionary<string, DateTime> _lastLinkRequest =
|
|
new Dictionary<string, DateTime>(StringComparer.Ordinal);
|
|
|
|
private readonly System.Random _codeRandom = new System.Random();
|
|
|
|
/// <summary>
|
|
/// <c>/link</c> — mint a one-time code for the caller and tell the sidecar about it.
|
|
///
|
|
/// <para>
|
|
/// <b>Chat, not console, and the reply is private.</b> Both frameworks consume a
|
|
/// <c>/</c> command rather than broadcasting it, and <c>SendReply</c> 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// The plugin does <b>not</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[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<string, object> 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 <color=#e0b34d>" + code + "</color>.");
|
|
SendReply(
|
|
player,
|
|
"Enter it on the website within " + (int)CodeTtl.TotalMinutes +
|
|
" minutes to link this Steam account. It works once.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>/unlink</c> — ask the website to sever this Steam id's link.
|
|
///
|
|
/// <para>
|
|
/// <b>It emits rather than deletes</b>, because the plugin holds no link to delete. The
|
|
/// site owns the record and applies this like any other event off the feed.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// It exists because the site <b>refuses</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[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<string, object> 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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Inbound <c>link.confirm</c> — the website has been given a code and wants to know
|
|
/// whose it is.
|
|
///
|
|
/// <para>
|
|
/// <b>This is the first command the website originates</b> 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// A code is consumed on the <b>first</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void HandleLinkConfirm(Dictionary<string, object> 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<string, object> ok = Frame("link.ok", "reply");
|
|
ok["steamId"] = pending.SteamId;
|
|
ok["name"] = pending.Name;
|
|
if (reqId != null)
|
|
ok["reqId"] = reqId;
|
|
|
|
EmitFrame(ok);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A refusal, with a reason the website can turn into a sentence.
|
|
///
|
|
/// <para>
|
|
/// <c>unknown</c> and <c>expired</c> are deliberately <b>separate</b> here and
|
|
/// deliberately <b>the same</b> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void EmitLinkError(string reqId, string reason)
|
|
{
|
|
Dictionary<string, object> frame = Frame("link.error", "reply");
|
|
frame["reason"] = reason;
|
|
if (reqId != null)
|
|
frame["reqId"] = reqId;
|
|
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
/// <summary>Drops every outstanding code belonging to one Steam id.</summary>
|
|
private void DropCodesFor(string steamId)
|
|
{
|
|
List<string> stale = null;
|
|
|
|
foreach (KeyValuePair<string, PendingLink> entry in _codes)
|
|
{
|
|
if (entry.Value != null && entry.Value.SteamId == steamId)
|
|
{
|
|
if (stale == null)
|
|
stale = new List<string>();
|
|
|
|
stale.Add(entry.Key);
|
|
}
|
|
}
|
|
|
|
if (stale == null)
|
|
return;
|
|
|
|
foreach (string key in stale)
|
|
_codes.Remove(key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drops expired codes and stale cooldowns.
|
|
///
|
|
/// <para>
|
|
/// Run on a timer rather than only on lookup, because an unconfirmed code is never
|
|
/// looked up — a player who runs <c>/link</c> and wanders off leaves an entry that
|
|
/// nothing else would ever remove.
|
|
/// </para>
|
|
/// </summary>
|
|
private void PurgeExpiredCodes()
|
|
{
|
|
DateTime now = DateTime.UtcNow;
|
|
List<string> dead = null;
|
|
|
|
foreach (KeyValuePair<string, PendingLink> entry in _codes)
|
|
{
|
|
if (entry.Value == null || now > entry.Value.Expires)
|
|
{
|
|
if (dead == null)
|
|
dead = new List<string>();
|
|
|
|
dead.Add(entry.Key);
|
|
}
|
|
}
|
|
|
|
if (dead != null)
|
|
{
|
|
foreach (string key in dead)
|
|
_codes.Remove(key);
|
|
}
|
|
|
|
List<string> cold = null;
|
|
|
|
foreach (KeyValuePair<string, DateTime> entry in _lastLinkRequest)
|
|
{
|
|
if (now - entry.Value > RequestCooldown)
|
|
{
|
|
if (cold == null)
|
|
cold = new List<string>();
|
|
|
|
cold.Add(entry.Key);
|
|
}
|
|
}
|
|
|
|
if (cold == null)
|
|
return;
|
|
|
|
foreach (string key in cold)
|
|
_lastLinkRequest.Remove(key);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A code from <see cref="CodeAlphabet"/>, retried until it does not collide.
|
|
///
|
|
/// <para>
|
|
/// <c>System.Random</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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.
|
|
|
|
/// <summary>
|
|
/// How many store operations one timer step performs.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private const int PermOpsPerStep = 200;
|
|
|
|
/// <summary>How often the sync drain runs while a job is in flight.</summary>
|
|
private const float PermStepSeconds = 0.05f;
|
|
|
|
/// <summary>
|
|
/// The ceiling on one sync, in operations.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private const int PermMaxOps = 20000;
|
|
|
|
/// <summary>The operations a compiled sync is made of. One store question each.</summary>
|
|
private enum PermOpKind
|
|
{
|
|
EnsureGroup,
|
|
EnsureGroupPermission,
|
|
EnsureMember,
|
|
EnsureGrant,
|
|
RetireGroupPermission,
|
|
RetireMember,
|
|
RetireGrant,
|
|
RetireGroup,
|
|
ScanPermission,
|
|
ScanGroup
|
|
}
|
|
|
|
private struct PermOp
|
|
{
|
|
public PermOpKind Kind;
|
|
|
|
/// <summary>A Steam id, or a group name — whichever the op is about.</summary>
|
|
public string Subject;
|
|
|
|
/// <summary>A permission name, a group name, or null.</summary>
|
|
public string Object;
|
|
|
|
/// <summary>Only <see cref="PermOpKind.EnsureGroup"/> uses these.</summary>
|
|
public string Title;
|
|
|
|
public int Rank;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private sealed class PermSyncJob
|
|
{
|
|
public string ReqId;
|
|
public string SetId;
|
|
public List<PermOp> Ops = new List<PermOp>();
|
|
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<string> Unresolved = new List<string>();
|
|
public readonly List<object> Foreign = new List<object>();
|
|
public readonly List<string> Pending = new List<string>();
|
|
|
|
/// <summary>
|
|
/// Group permissions an event lease holds (protocol 8). Neither applied to nor
|
|
/// reported foreign; what the site asked for is kept on the hold instead.
|
|
/// </summary>
|
|
public readonly List<object> Leased = new List<object>();
|
|
|
|
public readonly HashSet<string> LeasedSeen = new HashSet<string>();
|
|
|
|
/// <summary>Permission names already reported unresolved, so each is reported once.</summary>
|
|
public readonly HashSet<string> UnresolvedSeen = new HashSet<string>();
|
|
|
|
/// <summary>Who the site says should hold each managed permission, for the foreign scan.</summary>
|
|
public readonly Dictionary<string, HashSet<string>> DesiredHolders =
|
|
new Dictionary<string, HashSet<string>>();
|
|
|
|
/// <summary>Who the site says is in each managed group, and what it should carry.</summary>
|
|
public readonly Dictionary<string, HashSet<string>> DesiredMembers =
|
|
new Dictionary<string, HashSet<string>>();
|
|
|
|
public readonly Dictionary<string, HashSet<string>> DesiredGroupPermissions =
|
|
new Dictionary<string, HashSet<string>>();
|
|
}
|
|
|
|
private PermSyncJob _permJob;
|
|
private Timer _permTimer;
|
|
|
|
/// <summary>
|
|
/// True while this plugin is writing to the permission store.
|
|
///
|
|
/// <para>
|
|
/// The framework raises <c>OnUserPermissionGranted</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private bool _applyingPermissions;
|
|
|
|
/// <summary>The last report, for <c>rg.perms</c>. Diagnostics only.</summary>
|
|
private string _lastPermSummary = "none yet";
|
|
|
|
/// <summary>
|
|
/// Main thread. Everything this server knows about permissions and groups — the option
|
|
/// source the website's authoring form is built from (D33).
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void HandlePermCatalogue(string reqId)
|
|
{
|
|
Dictionary<string, object> reply = Frame("perm.catalogue", "reply");
|
|
|
|
var permissions = new List<object>();
|
|
string[] registered = permission.GetPermissions();
|
|
|
|
if (registered != null)
|
|
{
|
|
foreach (string name in registered)
|
|
{
|
|
if (!string.IsNullOrEmpty(name))
|
|
permissions.Add(name);
|
|
}
|
|
}
|
|
|
|
var groups = new List<object>();
|
|
string[] groupNames = permission.GetGroups();
|
|
|
|
if (groupNames != null)
|
|
{
|
|
foreach (string name in groupNames)
|
|
{
|
|
if (string.IsNullOrEmpty(name))
|
|
continue;
|
|
|
|
var row = new Dictionary<string, object>
|
|
{
|
|
{ "name", name },
|
|
{ "title", permission.GetGroupTitle(name) },
|
|
{ "rank", permission.GetGroupRank(name) }
|
|
};
|
|
|
|
var granted = new List<object>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Compiles a desired set into operations and starts draining it.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void HandlePermSync(Dictionary<string, object> 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<string, object> 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<string> 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<string, object> 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<string, object> 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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Drains up to <see cref="PermOpsPerStep"/> operations and, on the last
|
|
/// one, sends the report.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Main thread. One store question, and at most one store write.</summary>
|
|
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 (DeferToLease(job, op, true))
|
|
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 (DeferToLease(job, op, false))
|
|
break;
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void ScanPermission(PermSyncJob job, string perm)
|
|
{
|
|
if (!permission.PermissionExists(perm))
|
|
return;
|
|
|
|
HashSet<string> desired;
|
|
if (!job.DesiredHolders.TryGetValue(perm, out desired))
|
|
desired = new HashSet<string>();
|
|
|
|
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<string, object>
|
|
{
|
|
{ "kind", "grant" },
|
|
{ "subject", id },
|
|
{ "object", perm }
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private void ScanGroup(PermSyncJob job, string group)
|
|
{
|
|
if (!permission.GroupExists(group))
|
|
return;
|
|
|
|
HashSet<string> members;
|
|
if (!job.DesiredMembers.TryGetValue(group, out members))
|
|
members = new HashSet<string>();
|
|
|
|
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<string, object>
|
|
{
|
|
{ "kind", "member" },
|
|
{ "subject", id },
|
|
{ "object", group }
|
|
});
|
|
}
|
|
}
|
|
|
|
HashSet<string> wanted;
|
|
if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted))
|
|
wanted = new HashSet<string>();
|
|
|
|
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;
|
|
|
|
// A pair an event has borrowed is not somebody's console grant (§27.3).
|
|
if (LeasedGroupPermission(group, lowered) != null)
|
|
{
|
|
NoteLeased(job, group, lowered);
|
|
continue;
|
|
}
|
|
|
|
job.Foreign.Add(new Dictionary<string, object>
|
|
{
|
|
{ "kind", "group-permission" },
|
|
{ "subject", group },
|
|
{ "object", lowered }
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A sync op about a pair an event lease holds. The store is left to the lease; what
|
|
/// the site wants is recorded on the hold and becomes the value the lease gives back
|
|
/// (§27.3). Returns whether it deferred.
|
|
/// </summary>
|
|
private bool DeferToLease(PermSyncJob job, PermOp op, bool wants)
|
|
{
|
|
LeaseHold hold = LeasedGroupPermission(op.Subject, op.Object);
|
|
|
|
if (hold == null)
|
|
return false;
|
|
|
|
if (hold.SiteWants != wants)
|
|
{
|
|
hold.SiteWants = wants;
|
|
SaveLeases();
|
|
}
|
|
|
|
NoteLeased(job, op.Subject, op.Object);
|
|
return true;
|
|
}
|
|
|
|
private static void NoteLeased(PermSyncJob job, string group, string perm)
|
|
{
|
|
string id = Lower(group) + "/" + Lower(perm);
|
|
|
|
if (!job.LeasedSeen.Add(id))
|
|
return;
|
|
|
|
job.Leased.Add(new Dictionary<string, object>
|
|
{
|
|
{ "kind", "group-permission" },
|
|
{ "subject", Lower(group) },
|
|
{ "object", Lower(perm) }
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether this server has a plugin that registered the name, recording the answer once
|
|
/// per sync when it has not.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether a player holds a permission through a grant of their own, as opposed to
|
|
/// through a group.
|
|
///
|
|
/// <para>
|
|
/// <c>UserHasPermission</c> 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.
|
|
/// <c>GetPermissionUsers</c> reads the user's own set, which is the question.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The id out of a store listing entry.
|
|
///
|
|
/// <para>
|
|
/// Both <c>GetPermissionUsers</c> and <c>GetUsersInGroup</c> answer with the id and the
|
|
/// player's last known name stuck together — <c>76561198…(Wanderer)</c> — 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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<string, object>
|
|
{
|
|
{ "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<string, object> 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["leased"] = job.Leased;
|
|
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);
|
|
}
|
|
|
|
/// <summary>A refusal of the whole sync, with a reason the website turns into a sentence.</summary>
|
|
private void EmitPermError(string reqId, string reason)
|
|
{
|
|
Dictionary<string, object> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Says that the store changed under us, and by how much it is worth
|
|
/// caring.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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<string, object> 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.
|
|
|
|
/// <summary>How deep the walk goes before it stops trying to describe a tree.</summary>
|
|
private const int ConfigMaxDepth = 6;
|
|
|
|
/// <summary>How many files the walk will report.</summary>
|
|
private const int ConfigMaxFiles = 500;
|
|
|
|
/// <summary>
|
|
/// The largest file this plugin will read or write.
|
|
///
|
|
/// <para>
|
|
/// A file past it is reported and marked un-editable <b>with the reason</b>, 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private const int ConfigMaxFileBytes = 256 * 1024;
|
|
|
|
/// <summary>Files one write may touch. A plugin can own several; ten of them is not a tree.</summary>
|
|
private const int ConfigMaxWriteFiles = 10;
|
|
|
|
/// <summary>
|
|
/// How long a reload has to produce <c>OnPluginLoaded</c> before the write is undone.
|
|
///
|
|
/// <para>
|
|
/// <b>It is under half the sidecar's RPC timeout, and that is arithmetic rather than
|
|
/// taste.</b> 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 (<c>REPLY_TIMEOUT</c>, 10s, rust-link
|
|
/// <c>sidecar/src/rpc.rs</c>). 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private const float ConfigReloadWindowSeconds = 4f;
|
|
|
|
/// <summary>How much of the newest log file is read back when a reload does not arrive.</summary>
|
|
private const int ConfigLogTailBytes = 8 * 1024;
|
|
|
|
/// <summary>One file, resolved and checked, on its way to disk.</summary>
|
|
private sealed class StagedConfigWrite
|
|
{
|
|
public string RelPath;
|
|
public string FullPath;
|
|
public string Text;
|
|
|
|
/// <summary>What was there before, held for the rollback that may never be needed.</summary>
|
|
public string Original;
|
|
|
|
public bool Written;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A write that has landed and is waiting to hear whether the plugin came back.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private sealed class ConfigWriteJob
|
|
{
|
|
public string ReqId;
|
|
public string Reload;
|
|
public List<StagedConfigWrite> Staged;
|
|
public Timer Watch;
|
|
|
|
/// <summary>Whether the target was seen leaving. It separates "never started" from "did not come back".</summary>
|
|
public bool Unloaded;
|
|
}
|
|
|
|
private ConfigWriteJob _configJob;
|
|
|
|
private string _lastConfigSummary = "none yet";
|
|
|
|
/// <summary>Whether this is a Carbon host. Resolved once, by looking, never by compiling twice (R19).</summary>
|
|
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<object>();
|
|
bool truncated = false;
|
|
|
|
try
|
|
{
|
|
WalkConfig(root, root, 0, files, ref truncated);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
EmitConfigError(reqId, "unreadable", ex.Message);
|
|
return;
|
|
}
|
|
|
|
Dictionary<string, object> 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<string, object>
|
|
{
|
|
{ "depth", ConfigMaxDepth },
|
|
{ "files", ConfigMaxFiles },
|
|
{ "fileBytes", ConfigMaxFileBytes },
|
|
{ "writeFiles", ConfigMaxWriteFiles }
|
|
};
|
|
|
|
if (reqId != null)
|
|
reply["reqId"] = reqId;
|
|
|
|
EmitFrame(reply);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>config.read</c>, on the one
|
|
/// file somebody actually opened.
|
|
/// </summary>
|
|
private void WalkConfig(string root, string dir, int depth, List<object> 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<string, object>
|
|
{
|
|
{ "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<string, object> 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<string, object> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Replaces a set of files and reloads what owns them.
|
|
///
|
|
/// <para>
|
|
/// Everything is resolved, version-checked and parsed <b>before the first byte is
|
|
/// written</b> — the same posture as <c>perm.sync</c>, 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void HandleConfigWrite(Dictionary<string, object> cmd, string reqId)
|
|
{
|
|
if (_configJob != null)
|
|
{
|
|
EmitConfigError(reqId, "busy", "another configuration write is still waiting for its reload");
|
|
return;
|
|
}
|
|
|
|
var rows = new List<Dictionary<string, object>>(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<StagedConfigWrite>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (Dictionary<string, object> 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<string, object> 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;
|
|
}
|
|
}
|
|
|
|
// This plugin never reloads itself (§21.3), so a change to its own settings would
|
|
// otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first
|
|
// of them an operator is MEANT to change from the website (protocol 8, D76). It is
|
|
// re-read here instead. Host, Port and ServerId are locked on the website's side and
|
|
// still apply on the next reload, because changing them live would mean tearing down
|
|
// the link this answer is about to travel on.
|
|
bool ownApplied = ApplyOwnSettings(staged);
|
|
|
|
if (string.IsNullOrEmpty(reload))
|
|
{
|
|
SendConfigReport(reqId, staged, false, false,
|
|
ownApplied
|
|
? "EventsEnabled took effect at once; the bridge's other settings apply on its next reload"
|
|
: "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}'");
|
|
}
|
|
}
|
|
|
|
/// <summary>Main thread. The reload arrived: the plugin is back, so the write stands.</summary>
|
|
private void ConfigReloadArrived()
|
|
{
|
|
ConfigWriteJob job = _configJob;
|
|
|
|
if (job == null)
|
|
return;
|
|
|
|
StopConfigWatch();
|
|
_configJob = null;
|
|
|
|
SendConfigReport(job.ReqId, job.Staged, true, false, null, null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. The window closed with no <c>OnPluginLoaded</c>, which means the plugin
|
|
/// is down and the edit is why. Put every file back, reload again, and say so.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. What happened, and what is on disk now.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
private void SendConfigReport(
|
|
string reqId,
|
|
List<StagedConfigWrite> staged,
|
|
bool reloaded,
|
|
bool rolledBack,
|
|
string reason,
|
|
string log)
|
|
{
|
|
var files = new List<object>();
|
|
|
|
foreach (StagedConfigWrite file in staged)
|
|
{
|
|
var row = new Dictionary<string, object> { { "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<string, object> 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<string, object> frame = Frame("config.error", "reply");
|
|
frame["reason"] = reason;
|
|
|
|
if (!string.IsNullOrEmpty(detail))
|
|
frame["detail"] = detail;
|
|
|
|
if (reqId != null)
|
|
frame["reqId"] = reqId;
|
|
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
/// <summary>Main thread. Puts every file that landed back the way it was.</summary>
|
|
private void RestoreConfig(List<StagedConfigWrite> 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}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. If a write touched this plugin's own config, take the one setting that
|
|
/// is safe to change live — <c>EventsEnabled</c> — from what was written. Returns
|
|
/// whether it did.
|
|
/// </summary>
|
|
private bool ApplyOwnSettings(List<StagedConfigWrite> staged)
|
|
{
|
|
string own;
|
|
|
|
try
|
|
{
|
|
own = Path.GetFullPath(Path.Combine(Interface.Oxide.ConfigDirectory, Name + ".json"));
|
|
}
|
|
catch (Exception)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
foreach (StagedConfigWrite file in staged)
|
|
{
|
|
string full;
|
|
|
|
try
|
|
{
|
|
full = Path.GetFullPath(file.FullPath);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!string.Equals(full, own, StringComparison.OrdinalIgnoreCase))
|
|
continue;
|
|
|
|
try
|
|
{
|
|
var root = Newtonsoft.Json.Linq.JObject.Parse(file.Text);
|
|
Newtonsoft.Json.Linq.JToken flag = root["EventsEnabled"];
|
|
|
|
if (flag == null || flag.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || _config == null)
|
|
return false;
|
|
|
|
bool now = (bool)flag;
|
|
|
|
if (_config.EventsEnabled != now)
|
|
Puts($"EventsEnabled is now {now} (set from the website)");
|
|
|
|
_config.EventsEnabled = now;
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Puts($"could not re-read own settings: {Flatten(ex.Message)}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Keeps a copy of a file about to be overwritten, and the nine before it.
|
|
///
|
|
/// <para>
|
|
/// Under <c>DataDirectory</c>, 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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)
|
|
{
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The tail of the newest log file, read only when a reload failed.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reloads a plugin through the framework rather than by composing a console command.
|
|
///
|
|
/// <para>
|
|
/// The console is the one place the two frameworks genuinely differ — Carbon's commands
|
|
/// are <c>c.</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 <c>#if CARBON</c>, because one
|
|
/// byte-identical source file on both frameworks is the property R19 proved and is
|
|
/// worth keeping.
|
|
/// </para>
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns a path from a web form into a path on this host, or refuses it with a reason.
|
|
///
|
|
/// <para>
|
|
/// Phase 7 addressed things by name; this phase addresses them by <b>path</b>, 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.
|
|
/// </para>
|
|
/// </summary>
|
|
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('\\', '/');
|
|
}
|
|
|
|
/// <summary>
|
|
/// Which plugin a file probably belongs to: the folder for a nested file, the file's own
|
|
/// name otherwise.
|
|
///
|
|
/// <para>
|
|
/// <b>A guess, and it is labelled one all the way to the form.</b> A folder name is
|
|
/// convention, not contract. Infer it silently and the failure is the nastiest available
|
|
/// here: we reload the wrong plugin, watch <c>OnPluginLoaded</c> fire for <i>it</i>, and
|
|
/// report success while the plugin that was actually edited never re-read a thing.
|
|
/// </para>
|
|
/// </summary>
|
|
private static string PluginGuess(string rel)
|
|
{
|
|
int slash = rel.IndexOf('/');
|
|
|
|
return slash > 0
|
|
? rel.Substring(0, slash)
|
|
: Path.GetFileNameWithoutExtension(rel);
|
|
}
|
|
|
|
private List<object> LoadedPlugins()
|
|
{
|
|
var rows = new List<object>();
|
|
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<string, object>
|
|
{
|
|
{ "name", loaded.Name },
|
|
{ "title", loaded.Title },
|
|
{ "version", loaded.Version.ToString() },
|
|
{ "author", loaded.Author }
|
|
});
|
|
}
|
|
|
|
return rows;
|
|
}
|
|
|
|
/// <summary>
|
|
/// A file's identity for optimistic concurrency: its length and an FNV-1a hash of its
|
|
/// text.
|
|
///
|
|
/// <para>
|
|
/// 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
|
|
/// <c>System.Security.Cryptography</c> is a namespace whose availability under a plugin
|
|
/// compiler is one more thing that would have to be true on two frameworks.
|
|
/// </para>
|
|
/// </summary>
|
|
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}";
|
|
}
|
|
|
|
/// <summary>UTF-8 with no byte-order mark, which is what both frameworks write.</summary>
|
|
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;
|
|
}
|
|
|
|
// ---- leases (protocol 8, PLAN.md §27) ----
|
|
//
|
|
// An event does not edit the world. It borrows a value and gives it back. The website
|
|
// decides WHEN (core's `core.lease` reads the baseline, reserves the target and schedules
|
|
// the restore); this plugin decides WHETHER, holds the deadline, and does the writing.
|
|
//
|
|
// Four rules, each the answer to a way the obvious version goes wrong:
|
|
//
|
|
// • **The deadline lives here.** A hold arms a deadline and the plugin restores the
|
|
// baseline when it passes, whether or not the website is ever heard from again. Core's
|
|
// restore is the normal path; this is the fail-safe that makes an unattended change at
|
|
// 04:00 defensible. `holdMs` is authoritative and `untilMs` is display only — an
|
|
// absolute time computed on the website and honoured here is a deadline measured
|
|
// against two clocks.
|
|
// • **Release is compare-and-set, never a blind write.** A value that is no longer what
|
|
// the lease applied was moved by somebody on purpose: answer `lease.drifted` with what
|
|
// is there now and leave it alone.
|
|
// • **Values cross as text and compare parsed.** `1` and `1.0` are one number and two
|
|
// strings; a drift check on formatted numbers blames an innocent operator.
|
|
// • **A hold longer than the ceiling is refused, never clamped.** A clamp gives the
|
|
// website a shorter lease than it believes it has, and the website is the half that
|
|
// schedules the restore.
|
|
//
|
|
// And one that is Rust's own (§27.1): **a convar hold is memory-only and a restart is a
|
|
// free restore, but a group permission is persisted by the framework.** So holds are
|
|
// written to a file under this plugin's data directory with the boot they were taken
|
|
// under, and on load a convar hold from an earlier boot is dropped (the restart already
|
|
// gave it back) while a permission hold is re-armed — or restored at once, if its
|
|
// deadline passed while the server was down.
|
|
|
|
/// <summary>
|
|
/// The longest a lease may be held: seven days (D77). The website declares the same
|
|
/// ceiling; this one is independent of it and exists for the case where the website
|
|
/// is wrong.
|
|
/// </summary>
|
|
private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L;
|
|
|
|
/// <summary>How often held deadlines are checked. A lease ends within a second of its time.</summary>
|
|
private const float LeaseCheckSeconds = 1f;
|
|
|
|
/// <summary>The wire key of the one lease that is not a convar.</summary>
|
|
private const string GroupPermissionKey = "group.permission";
|
|
|
|
/// <summary>
|
|
/// One convar the website may borrow, and the bounds this plugin holds it to. The
|
|
/// website's declaration carries the same bounds so a bad value is refused on a form;
|
|
/// these are what is true when the website is wrong.
|
|
/// </summary>
|
|
private sealed class LeaseKey
|
|
{
|
|
public string Key;
|
|
public string Family;
|
|
public double Min;
|
|
public double Max;
|
|
|
|
public LeaseKey(string key, string family, double min, double max)
|
|
{
|
|
Key = key;
|
|
Family = family;
|
|
Min = min;
|
|
Max = max;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only).
|
|
///
|
|
/// <para>
|
|
/// An allowlist rather than "any convar", because the catalogue is a promise: a key
|
|
/// here has been set, observed changing something in a running game, and restored. A
|
|
/// value the server reads once at boot applies cleanly, reads back cleanly and does
|
|
/// nothing, and nothing but a live walk can tell the two apart (§9).
|
|
/// </para>
|
|
/// </summary>
|
|
private static readonly LeaseKey[] LeaseKeys =
|
|
{
|
|
new LeaseKey("decay.scale", "decay", 0, 10),
|
|
|
|
// Every population is per square kilometre — vehicles too, whatever the game's own
|
|
// help text says ("active on the server"): on a 9 km² map a value of 3 is a target of
|
|
// 27. Each was walked: set, seen moving `spawn.report`'s target, and given back.
|
|
new LeaseKey("bear.population", "population", 0, 50),
|
|
new LeaseKey("polarbear.population", "population", 0, 50),
|
|
new LeaseKey("boar.population", "population", 0, 50),
|
|
new LeaseKey("chicken.population", "population", 0, 50),
|
|
new LeaseKey("stag.population", "population", 0, 50),
|
|
new LeaseKey("wolf2.population", "population", 0, 50),
|
|
new LeaseKey("ridablehorse.population", "population", 0, 50),
|
|
new LeaseKey("crocodile.population", "population", 0, 50),
|
|
new LeaseKey("panther.population", "population", 0, 50),
|
|
new LeaseKey("tiger.population", "population", 0, 50),
|
|
new LeaseKey("snakehazard.population", "population", 0, 50),
|
|
|
|
new LeaseKey("minicopter.population", "population", 0, 50),
|
|
new LeaseKey("scraptransporthelicopter.population", "population", 0, 50),
|
|
new LeaseKey("hotairballoon.population", "population", 0, 50),
|
|
new LeaseKey("modularcar.population", "population", 0, 50),
|
|
new LeaseKey("motorrowboat.population", "population", 0, 50),
|
|
new LeaseKey("rhib.rhibpopulation", "population", 0, 50),
|
|
new LeaseKey("traincar.population", "population", 0, 50),
|
|
|
|
// The population system's own scalars. Only the two MINIMUMS: the game scales between
|
|
// min and max by how full the server is, so on a rig with nobody on it the effective
|
|
// rate and density ARE the minimums, and `spawn.scalars` showed both moving. The two
|
|
// maximums only matter with players online, which no walk has had — so they are not
|
|
// lent until one does (§9: a key that cannot be seen doing something is not advertised).
|
|
new LeaseKey("spawn.min_rate", "spawn", 0, 10),
|
|
new LeaseKey("spawn.min_density", "spawn", 0, 10)
|
|
};
|
|
|
|
/// <summary>
|
|
/// One value this plugin is holding. Persisted as it is, so every field is public and
|
|
/// plain.
|
|
/// </summary>
|
|
private sealed class LeaseHold
|
|
{
|
|
public string Key;
|
|
|
|
/// <summary>Null for a convar; <c>group/permission</c> for a group permission.</summary>
|
|
public string Target;
|
|
|
|
public string Baseline;
|
|
public string Applied;
|
|
|
|
/// <summary>When the hold ends, on this host's clock: taken time plus <c>holdMs</c>.</summary>
|
|
public long DeadlineMs;
|
|
|
|
/// <summary>The website's absolute deadline, for display. Never compared against.</summary>
|
|
public long UntilMs;
|
|
|
|
public string RunId;
|
|
public string BootId;
|
|
public long TakenMs;
|
|
|
|
/// <summary>
|
|
/// What the website's permission set said about this pair while the lease held it:
|
|
/// null for nothing, else whether the site wants it granted. A group permission
|
|
/// held by a lease belongs to the lease until it ends; a sync that arrives in the
|
|
/// meantime is recorded here and honoured at release instead of being applied
|
|
/// under the lease's feet (§27.3).
|
|
/// </summary>
|
|
public bool? SiteWants;
|
|
}
|
|
|
|
/// <summary>Holds by <see cref="LeaseSlot"/>. Main thread only.</summary>
|
|
private Dictionary<string, LeaseHold> _leases = new Dictionary<string, LeaseHold>();
|
|
|
|
/// <summary>Convars by lowered full name, resolved once. Null until first needed.</summary>
|
|
private Dictionary<string, ConsoleSystem.Command> _convars;
|
|
|
|
private Timer _leaseTimer;
|
|
|
|
/// <summary>The last thing a lease did, for <c>rg.leases</c>. Diagnostics only.</summary>
|
|
private string _lastLeaseSummary = "none yet";
|
|
|
|
private static string LeaseSlot(string key, string target)
|
|
{
|
|
return string.IsNullOrEmpty(target) ? key : key + "#" + target;
|
|
}
|
|
|
|
private static LeaseKey FindLeaseKey(string key)
|
|
{
|
|
foreach (LeaseKey k in LeaseKeys)
|
|
{
|
|
if (string.Equals(k.Key, key, StringComparison.OrdinalIgnoreCase))
|
|
return k;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. The game's convar, or null when this build has no such convar or it
|
|
/// is one the plugin refuses to hold.
|
|
///
|
|
/// <para>
|
|
/// <b>A <c>Saved</c> convar is refused</b> (§27.1). The game writes those to
|
|
/// <c>serverauto.cfg</c>, so a hold on one would survive the restart that is
|
|
/// supposed to end it — and a lease that outlives its own fail-safe is the one thing
|
|
/// a lease exists to prevent. None of the allowlist is <c>Saved</c> today; this is
|
|
/// what keeps a Facepunch change to that from becoming our bug silently.
|
|
/// </para>
|
|
/// </summary>
|
|
private ConsoleSystem.Command LeaseConvar(string key, out string refusal)
|
|
{
|
|
refusal = null;
|
|
|
|
if (_convars == null)
|
|
{
|
|
_convars = new Dictionary<string, ConsoleSystem.Command>(StringComparer.OrdinalIgnoreCase);
|
|
ConsoleSystem.Command[] all = ConsoleSystem.Index.All;
|
|
|
|
if (all != null)
|
|
{
|
|
foreach (ConsoleSystem.Command c in all)
|
|
{
|
|
if (c != null && c.Variable && !string.IsNullOrEmpty(c.FullName))
|
|
_convars[c.FullName] = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
ConsoleSystem.Command command;
|
|
|
|
if (!_convars.TryGetValue(key, out command) || command == null || command.SetOveride == null)
|
|
{
|
|
refusal = $"this server has no convar '{key}'";
|
|
return null;
|
|
}
|
|
|
|
if (command.Saved)
|
|
{
|
|
refusal = $"'{key}' is saved to serverauto.cfg, so a restart would not give it back";
|
|
return null;
|
|
}
|
|
|
|
return command;
|
|
}
|
|
|
|
private static bool TryParseNumber(string text, out double value)
|
|
{
|
|
return double.TryParse(
|
|
text, System.Globalization.NumberStyles.Float,
|
|
System.Globalization.CultureInfo.InvariantCulture, out value);
|
|
}
|
|
|
|
private static bool TryParseFlag(string text, out bool value)
|
|
{
|
|
value = false;
|
|
|
|
if (text == null)
|
|
return false;
|
|
|
|
string t = text.Trim().ToLowerInvariant();
|
|
|
|
if (t == "true" || t == "1")
|
|
{
|
|
value = true;
|
|
return true;
|
|
}
|
|
|
|
if (t == "false" || t == "0")
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether two values are the same value, parsed. A convar reads back through
|
|
/// <c>float.ToString()</c>, so the tolerance is a float's, not a double's.
|
|
/// </summary>
|
|
private static bool SameLeaseValue(string key, string a, string b)
|
|
{
|
|
if (key == GroupPermissionKey)
|
|
{
|
|
bool x, y;
|
|
return TryParseFlag(a, out x) && TryParseFlag(b, out y) && x == y;
|
|
}
|
|
|
|
double p, q;
|
|
|
|
if (!TryParseNumber(a, out p) || !TryParseNumber(b, out q))
|
|
return string.Equals(a, b, StringComparison.Ordinal);
|
|
|
|
return Math.Abs(p - q) <= 1e-4 * Math.Max(1.0, Math.Max(Math.Abs(p), Math.Abs(q)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Splits a group-permission target at its LAST slash. A permission name never
|
|
/// contains one; a group name is a free-form string an operator typed, so it is the
|
|
/// half allowed to.
|
|
/// </summary>
|
|
private static bool SplitGroupTarget(string target, out string group, out string perm)
|
|
{
|
|
group = null;
|
|
perm = null;
|
|
|
|
if (string.IsNullOrEmpty(target))
|
|
return false;
|
|
|
|
int slash = target.LastIndexOf('/');
|
|
|
|
if (slash <= 0 || slash >= target.Length - 1)
|
|
return false;
|
|
|
|
group = Lower(target.Substring(0, slash));
|
|
perm = Lower(target.Substring(slash + 1));
|
|
return !string.IsNullOrEmpty(group) && !string.IsNullOrEmpty(perm);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. The value a key holds right now, as text — or null with a reason.
|
|
/// <paramref name="gone"/> is set when the target itself no longer exists, which a
|
|
/// release treats as nothing left to give back rather than as a failure.
|
|
/// </summary>
|
|
private string ReadLeaseValue(string key, string target, out string refusal, out bool gone)
|
|
{
|
|
gone = false;
|
|
|
|
if (key == GroupPermissionKey)
|
|
{
|
|
string group, perm;
|
|
|
|
if (!SplitGroupTarget(target, out group, out perm))
|
|
{
|
|
refusal = "a group permission is named as group/permission";
|
|
return null;
|
|
}
|
|
|
|
if (!permission.GroupExists(group))
|
|
{
|
|
gone = true;
|
|
refusal = $"there is no group '{group}' on this server";
|
|
return null;
|
|
}
|
|
|
|
refusal = null;
|
|
return permission.GroupHasPermission(group, perm) ? "true" : "false";
|
|
}
|
|
|
|
if (FindLeaseKey(key) == null)
|
|
{
|
|
refusal = $"'{key}' is not a value this server lends";
|
|
return null;
|
|
}
|
|
|
|
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
|
|
return command == null ? null : command.String;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Writes a value, then reads it back — the read-back is the answer, not
|
|
/// the call. Returns null on success, or why not.
|
|
/// </summary>
|
|
private string WriteLeaseValue(string key, string target, string value)
|
|
{
|
|
if (key == GroupPermissionKey)
|
|
{
|
|
string group, perm;
|
|
|
|
if (!SplitGroupTarget(target, out group, out perm))
|
|
return "a group permission is named as group/permission";
|
|
|
|
bool grant;
|
|
|
|
if (!TryParseFlag(value, out grant))
|
|
return $"'{value}' is not true or false";
|
|
|
|
// Our own write raises OnGroupPermissionGranted/Revoked. Without this the site
|
|
// would be told a human had just edited the store, and it would reconcile against
|
|
// the lease's own change (§27.3).
|
|
_applyingPermissions = true;
|
|
|
|
try
|
|
{
|
|
// **`null`, not `this`, as the owner.** With an owner, Oxide's grant first asks
|
|
// `PermissionExists(perm, owner)` — whether THAT plugin registered the name —
|
|
// and returns without a word when it did not. Every permission a lease borrows
|
|
// belongs to some other plugin, so `this` made every grant a silent no-op. The
|
|
// read-back below is what caught it.
|
|
if (grant)
|
|
permission.GrantGroupPermission(group, perm, null);
|
|
else
|
|
permission.RevokeGroupPermission(group, perm);
|
|
}
|
|
finally
|
|
{
|
|
_applyingPermissions = false;
|
|
}
|
|
|
|
if (permission.GroupHasPermission(group, perm) != grant)
|
|
return $"the store did not {(grant ? "grant" : "revoke")} '{perm}' on '{group}'";
|
|
|
|
return null;
|
|
}
|
|
|
|
string refusal;
|
|
ConsoleSystem.Command command = LeaseConvar(key, out refusal);
|
|
|
|
if (command == null)
|
|
return refusal;
|
|
|
|
command.Set(value);
|
|
|
|
if (!SameLeaseValue(key, command.String, value))
|
|
return $"'{key}' reads back as {command.String} after being set to {value}";
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Protocol 8's catalogue: every key this server lends and what it holds
|
|
/// now, and every hold in force.
|
|
///
|
|
/// <para>
|
|
/// Narrowed by <c>key</c> (and <c>target</c>) when asked, because a group permission
|
|
/// has no single current value — it is one per pair — and a caller asking about one
|
|
/// pair wants that pair's row, not the store.
|
|
/// </para>
|
|
/// </summary>
|
|
private void HandleLeaseList(Dictionary<string, object> cmd, string reqId)
|
|
{
|
|
string onlyKey = Lower(StringField(cmd, "key"));
|
|
string onlyTarget = StringField(cmd, "target");
|
|
var rows = new List<object>();
|
|
|
|
foreach (LeaseKey k in LeaseKeys)
|
|
{
|
|
if (!string.IsNullOrEmpty(onlyKey) && onlyKey != k.Key)
|
|
continue;
|
|
|
|
rows.Add(LeaseRow(k.Key, k.Family, k.Min, k.Max, null));
|
|
}
|
|
|
|
if (onlyKey == GroupPermissionKey && !string.IsNullOrEmpty(onlyTarget))
|
|
rows.Add(LeaseRow(GroupPermissionKey, "group", 0, 1, onlyTarget));
|
|
|
|
var holds = new List<object>();
|
|
|
|
foreach (LeaseHold h in _leases.Values)
|
|
holds.Add(HoldBody(h));
|
|
|
|
Dictionary<string, object> reply = Frame("lease.list", "reply");
|
|
reply["eventsEnabled"] = _config != null && _config.EventsEnabled;
|
|
reply["maxHoldMs"] = LeaseMaxHoldMs;
|
|
reply["leases"] = rows;
|
|
reply["holds"] = holds;
|
|
|
|
if (reqId != null)
|
|
reply["reqId"] = reqId;
|
|
|
|
EmitFrame(reply);
|
|
}
|
|
|
|
private Dictionary<string, object> LeaseRow(string key, string family, double min, double max, string target)
|
|
{
|
|
var row = new Dictionary<string, object>
|
|
{
|
|
{ "key", key },
|
|
{ "family", family }
|
|
};
|
|
|
|
if (key != GroupPermissionKey)
|
|
{
|
|
row["min"] = min;
|
|
row["max"] = max;
|
|
}
|
|
else
|
|
{
|
|
row["target"] = target;
|
|
}
|
|
|
|
string refusal;
|
|
bool gone;
|
|
string current = ReadLeaseValue(key, target, out refusal, out gone);
|
|
|
|
if (current != null)
|
|
row["current"] = current;
|
|
else
|
|
row["unreadable"] = refusal;
|
|
|
|
if (gone)
|
|
row["targetGone"] = true;
|
|
|
|
LeaseHold hold;
|
|
bool held = _leases.TryGetValue(LeaseSlot(key, target), out hold);
|
|
row["held"] = held;
|
|
|
|
if (held)
|
|
{
|
|
row["baseline"] = hold.Baseline;
|
|
row["applied"] = hold.Applied;
|
|
row["untilMs"] = hold.UntilMs;
|
|
row["runId"] = hold.RunId;
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
private static Dictionary<string, object> HoldBody(LeaseHold h)
|
|
{
|
|
var body = new Dictionary<string, object>
|
|
{
|
|
{ "key", h.Key },
|
|
{ "baseline", h.Baseline },
|
|
{ "applied", h.Applied },
|
|
{ "untilMs", h.UntilMs },
|
|
{ "remainingMs", Math.Max(0L, h.DeadlineMs - NowMs()) }
|
|
};
|
|
|
|
if (!string.IsNullOrEmpty(h.Target))
|
|
body["target"] = h.Target;
|
|
|
|
if (!string.IsNullOrEmpty(h.RunId))
|
|
body["runId"] = h.RunId;
|
|
|
|
return body;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Take a value for a while.
|
|
///
|
|
/// <para>
|
|
/// <b>A key this plugin already holds keeps its ORIGINAL baseline.</b> Core reserves
|
|
/// a target before it applies, so a second holder is refused on the website's side;
|
|
/// a second apply arriving here means the first one's answer was lost, and core is
|
|
/// trying again. The value to give back at the end is still what was there before
|
|
/// anybody borrowed it, not the first attempt's value.
|
|
/// </para>
|
|
/// </summary>
|
|
private void HandleLeaseApply(Dictionary<string, object> cmd, string reqId)
|
|
{
|
|
string key = Lower(StringField(cmd, "key"));
|
|
string target = key == GroupPermissionKey ? StringField(cmd, "target") : null;
|
|
string family = Lower(StringField(cmd, "family"));
|
|
string value = StringField(cmd, "value");
|
|
long holdMs = LongField(cmd, "holdMs");
|
|
long untilMs = LongField(cmd, "untilMs");
|
|
|
|
if (_config == null || !_config.EventsEnabled)
|
|
{
|
|
EmitLeaseError(reqId, "events-disabled",
|
|
"events are switched off on this server — set EventsEnabled in the RunicGateway plugin config");
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(key) || value == null)
|
|
{
|
|
EmitLeaseError(reqId, "malformed", "a lease names a key and a value");
|
|
return;
|
|
}
|
|
|
|
if (holdMs <= 0)
|
|
{
|
|
EmitLeaseError(reqId, "malformed", "a lease is held for a positive number of milliseconds");
|
|
return;
|
|
}
|
|
|
|
if (holdMs > LeaseMaxHoldMs)
|
|
{
|
|
EmitLeaseError(reqId, "too-long",
|
|
$"this server holds a lease for at most {LeaseMaxHoldMs / 60000L} minutes, not {holdMs / 60000L}");
|
|
return;
|
|
}
|
|
|
|
if (key == GroupPermissionKey)
|
|
{
|
|
bool flag;
|
|
|
|
if (!TryParseFlag(value, out flag))
|
|
{
|
|
EmitLeaseError(reqId, "out-of-range", $"'{value}' is not true or false");
|
|
return;
|
|
}
|
|
|
|
string group, perm;
|
|
|
|
if (!SplitGroupTarget(target, out group, out perm))
|
|
{
|
|
EmitLeaseError(reqId, "malformed", "a group permission is named as group/permission");
|
|
return;
|
|
}
|
|
|
|
// The same pre-check the permission mirror makes (§12.2 rule 1): a grant of a
|
|
// name nothing registered returns void and does nothing, and the lease would then
|
|
// record a hold on a value that never changed.
|
|
if (!permission.PermissionExists(perm))
|
|
{
|
|
EmitLeaseError(reqId, "unresolved", $"no loaded plugin on this server registers '{perm}'");
|
|
return;
|
|
}
|
|
|
|
target = group + "/" + perm;
|
|
value = flag ? "true" : "false";
|
|
}
|
|
else
|
|
{
|
|
LeaseKey k = FindLeaseKey(key);
|
|
|
|
if (k == null)
|
|
{
|
|
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a value this server lends");
|
|
return;
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(family) && family != k.Family)
|
|
{
|
|
EmitLeaseError(reqId, "unknown-key", $"'{key}' is not a {family} value");
|
|
return;
|
|
}
|
|
|
|
double number;
|
|
|
|
if (!TryParseNumber(value, out number) || number < k.Min || number > k.Max)
|
|
{
|
|
EmitLeaseError(reqId, "out-of-range", $"{key} takes {k.Min} to {k.Max}, not {value}");
|
|
return;
|
|
}
|
|
}
|
|
|
|
string slot = LeaseSlot(key, target);
|
|
LeaseHold existing;
|
|
_leases.TryGetValue(slot, out existing);
|
|
|
|
string refusal;
|
|
bool gone;
|
|
string before = ReadLeaseValue(key, target, out refusal, out gone);
|
|
|
|
if (before == null)
|
|
{
|
|
EmitLeaseError(reqId, gone ? "target-gone" : "unreadable", refusal);
|
|
return;
|
|
}
|
|
|
|
string failed = WriteLeaseValue(key, target, value);
|
|
|
|
if (failed != null)
|
|
{
|
|
// Put back what was there; a half-applied lease is not a state to leave behind.
|
|
WriteLeaseValue(key, target, before);
|
|
EmitLeaseError(reqId, "refused", failed);
|
|
return;
|
|
}
|
|
|
|
long now = NowMs();
|
|
|
|
var hold = new LeaseHold
|
|
{
|
|
Key = key,
|
|
Target = target,
|
|
Baseline = existing != null ? existing.Baseline : before,
|
|
Applied = value,
|
|
DeadlineMs = now + holdMs,
|
|
UntilMs = untilMs > 0 ? untilMs : now + holdMs,
|
|
RunId = StringField(cmd, "runId"),
|
|
BootId = _bootId,
|
|
TakenMs = now,
|
|
SiteWants = existing != null ? existing.SiteWants : null
|
|
};
|
|
|
|
_leases[slot] = hold;
|
|
SaveLeases();
|
|
EnsureLeaseTimer();
|
|
|
|
_lastLeaseSummary = $"applied {slot}={value} (baseline {hold.Baseline}) for {holdMs / 1000L}s";
|
|
Puts($"lease {_lastLeaseSummary}");
|
|
|
|
Dictionary<string, object> reply = Frame("lease.ok", "reply");
|
|
reply["key"] = key;
|
|
|
|
if (!string.IsNullOrEmpty(target))
|
|
reply["target"] = target;
|
|
|
|
reply["baseline"] = hold.Baseline;
|
|
reply["applied"] = hold.Applied;
|
|
reply["untilMs"] = hold.UntilMs;
|
|
|
|
if (reqId != null)
|
|
reply["reqId"] = reqId;
|
|
|
|
EmitFrame(reply);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Give a value back — compare-and-set.
|
|
///
|
|
/// <para>
|
|
/// The comparison is against what the LEASE applied, from this plugin's own record
|
|
/// when it has one, else from the website's <c>expected</c>. The record is missing
|
|
/// when the deadline already fired, or when a restart dropped a convar hold; both of
|
|
/// those have already given the value back, and the compare then finds baseline there
|
|
/// and answers success without writing anything.
|
|
/// </para>
|
|
/// </summary>
|
|
private void HandleLeaseRelease(Dictionary<string, object> cmd, string reqId)
|
|
{
|
|
string key = Lower(StringField(cmd, "key"));
|
|
string target = StringField(cmd, "target");
|
|
|
|
if (key == GroupPermissionKey)
|
|
{
|
|
string group, perm;
|
|
|
|
if (SplitGroupTarget(target, out group, out perm))
|
|
target = group + "/" + perm;
|
|
}
|
|
else
|
|
{
|
|
target = null;
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(key))
|
|
{
|
|
EmitLeaseError(reqId, "malformed", "a release names a key");
|
|
return;
|
|
}
|
|
|
|
LeaseHold hold;
|
|
_leases.TryGetValue(LeaseSlot(key, target), out hold);
|
|
|
|
string expected = hold != null ? hold.Applied : StringField(cmd, "expected");
|
|
string baseline = hold != null ? hold.Baseline : StringField(cmd, "baseline");
|
|
|
|
Dictionary<string, object> outcome = Restore(key, target, expected, baseline, hold, "release");
|
|
|
|
if (reqId != null)
|
|
outcome["reqId"] = reqId;
|
|
|
|
EmitFrame(outcome);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. The one place a value is given back, by a release or a deadline. The
|
|
/// hold is ended either way — a drifted value is left alone, but the lease is over.
|
|
/// </summary>
|
|
private Dictionary<string, object> Restore(
|
|
string key, string target, string expected, string baseline, LeaseHold hold, string why)
|
|
{
|
|
string slot = LeaseSlot(key, target);
|
|
|
|
// A group permission the site asked for during the hold is given to the site, not
|
|
// back to the baseline: the lease borrowed the pair, the site owns what it becomes.
|
|
string restoreTo = baseline;
|
|
|
|
if (hold != null && hold.SiteWants.HasValue)
|
|
restoreTo = hold.SiteWants.Value ? "true" : "false";
|
|
|
|
string refusal;
|
|
bool gone;
|
|
string current = ReadLeaseValue(key, target, out refusal, out gone);
|
|
|
|
if (current == null)
|
|
{
|
|
if (gone)
|
|
{
|
|
// The group was deleted mid-hold: nothing is owed and nothing is out there.
|
|
_leases.Remove(slot);
|
|
SaveLeases();
|
|
_lastLeaseSummary = $"{why} {slot}: target gone";
|
|
|
|
Dictionary<string, object> goneFrame = Frame("lease.ok", "reply");
|
|
goneFrame["key"] = key;
|
|
goneFrame["targetGone"] = true;
|
|
|
|
if (!string.IsNullOrEmpty(target))
|
|
goneFrame["target"] = target;
|
|
|
|
return goneFrame;
|
|
}
|
|
|
|
// The value cannot be read, so it cannot be compared. The hold stays: the next
|
|
// release, or the next deadline check, tries again.
|
|
Dictionary<string, object> error = Frame("lease.error", "reply");
|
|
error["reason"] = "unreadable";
|
|
error["message"] = refusal;
|
|
return error;
|
|
}
|
|
|
|
if (restoreTo != null && SameLeaseValue(key, current, restoreTo))
|
|
{
|
|
// Already given back — by a deadline, a restart, or somebody who restored it by
|
|
// hand. Nothing to write, and it is a success.
|
|
_leases.Remove(slot);
|
|
SaveLeases();
|
|
_lastLeaseSummary = $"{why} {slot}: already {current}";
|
|
return RestoredFrame(key, target, current);
|
|
}
|
|
|
|
if (expected == null || !SameLeaseValue(key, current, expected))
|
|
{
|
|
_leases.Remove(slot);
|
|
SaveLeases();
|
|
_lastLeaseSummary = $"{why} {slot}: drifted to {current}, left alone";
|
|
Puts($"lease {_lastLeaseSummary}");
|
|
|
|
Dictionary<string, object> drifted = Frame("lease.drifted", "reply");
|
|
drifted["key"] = key;
|
|
|
|
if (!string.IsNullOrEmpty(target))
|
|
drifted["target"] = target;
|
|
|
|
drifted["current"] = current;
|
|
drifted["expected"] = expected;
|
|
return drifted;
|
|
}
|
|
|
|
string failed = restoreTo == null ? "no baseline to restore" : WriteLeaseValue(key, target, restoreTo);
|
|
|
|
if (failed != null)
|
|
{
|
|
Dictionary<string, object> error = Frame("lease.error", "reply");
|
|
error["reason"] = "refused";
|
|
error["message"] = failed;
|
|
return error;
|
|
}
|
|
|
|
_leases.Remove(slot);
|
|
SaveLeases();
|
|
_lastLeaseSummary = $"{why} {slot}: restored {restoreTo}";
|
|
Puts($"lease {_lastLeaseSummary}");
|
|
return RestoredFrame(key, target, restoreTo);
|
|
}
|
|
|
|
private Dictionary<string, object> RestoredFrame(string key, string target, string value)
|
|
{
|
|
Dictionary<string, object> ok = Frame("lease.ok", "reply");
|
|
ok["key"] = key;
|
|
|
|
if (!string.IsNullOrEmpty(target))
|
|
ok["target"] = target;
|
|
|
|
ok["restored"] = value;
|
|
return ok;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread, every second while anything is held. The fail-safe: a deadline that
|
|
/// passes gives the value back without anybody asking, and says so.
|
|
/// </summary>
|
|
private void CheckLeaseDeadlines()
|
|
{
|
|
if (_leases.Count == 0)
|
|
{
|
|
StopLeaseTimer();
|
|
return;
|
|
}
|
|
|
|
long now = NowMs();
|
|
List<LeaseHold> due = null;
|
|
|
|
foreach (LeaseHold h in _leases.Values)
|
|
{
|
|
if (h.DeadlineMs <= now)
|
|
{
|
|
if (due == null)
|
|
due = new List<LeaseHold>();
|
|
|
|
due.Add(h);
|
|
}
|
|
}
|
|
|
|
if (due == null)
|
|
return;
|
|
|
|
foreach (LeaseHold h in due)
|
|
Expire(h);
|
|
}
|
|
|
|
private void Expire(LeaseHold hold)
|
|
{
|
|
Dictionary<string, object> outcome = Restore(
|
|
hold.Key, hold.Target, hold.Applied, hold.Baseline, hold, "expired");
|
|
|
|
string kind = StringField(outcome, "kind");
|
|
|
|
// An unreadable value keeps the hold; the next tick tries again rather than
|
|
// announcing an expiry that did not happen.
|
|
if (kind == "lease.error")
|
|
return;
|
|
|
|
Dictionary<string, object> frame = Frame("lease.expired", "event");
|
|
frame["key"] = hold.Key;
|
|
|
|
if (!string.IsNullOrEmpty(hold.Target))
|
|
frame["target"] = hold.Target;
|
|
|
|
if (!string.IsNullOrEmpty(hold.RunId))
|
|
frame["runId"] = hold.RunId;
|
|
|
|
frame["drifted"] = kind == "lease.drifted";
|
|
|
|
object current;
|
|
if (outcome.TryGetValue("current", out current))
|
|
frame["current"] = current;
|
|
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
private void EnsureLeaseTimer()
|
|
{
|
|
if (_leaseTimer == null && _leases.Count > 0)
|
|
_leaseTimer = timer.Every(LeaseCheckSeconds, CheckLeaseDeadlines);
|
|
}
|
|
|
|
private void StopLeaseTimer()
|
|
{
|
|
if (_leaseTimer != null)
|
|
{
|
|
_leaseTimer.Destroy();
|
|
_leaseTimer = null;
|
|
}
|
|
}
|
|
|
|
private void EmitLeaseError(string reqId, string reason, string message)
|
|
{
|
|
Dictionary<string, object> frame = Frame("lease.error", "reply");
|
|
frame["reason"] = reason;
|
|
|
|
if (!string.IsNullOrEmpty(message))
|
|
frame["message"] = message;
|
|
|
|
if (reqId != null)
|
|
frame["reqId"] = reqId;
|
|
|
|
_lastLeaseSummary = $"refused ({reason}): {message}";
|
|
EmitFrame(frame);
|
|
}
|
|
|
|
private string LeasesPath()
|
|
{
|
|
return Path.Combine(Interface.Oxide.DataDirectory, Name, "leases.json");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread. Written on every change, because the whole point of the file is the
|
|
/// crash nobody saw coming. It is a handful of rows.
|
|
/// </summary>
|
|
private void SaveLeases()
|
|
{
|
|
try
|
|
{
|
|
string path = LeasesPath();
|
|
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
|
|
|
var rows = new List<LeaseHold>(_leases.Values);
|
|
string tmp = path + ".tmp";
|
|
File.WriteAllText(tmp, JsonConvert.SerializeObject(rows, Formatting.Indented), Utf8NoBom);
|
|
|
|
if (File.Exists(path))
|
|
File.Delete(path);
|
|
|
|
File.Move(tmp, path);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Puts($"could not save leases: {Flatten(ex.Message)}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main thread, once per load, with the world available. Re-arms what is still owed.
|
|
///
|
|
/// <para>
|
|
/// A convar hold taken under an earlier BOOT is dropped: the restart put the game's
|
|
/// own value back, and re-applying it would resurrect a lease the restart ended. A
|
|
/// convar hold from THIS boot survives a plugin reload — the value is still in the
|
|
/// game's memory, and a config save from the website must not end an event. A group
|
|
/// permission hold is re-armed whatever the boot, because the framework kept it.
|
|
/// </para>
|
|
/// </summary>
|
|
private void LoadLeases()
|
|
{
|
|
_leases = new Dictionary<string, LeaseHold>();
|
|
|
|
List<LeaseHold> rows = null;
|
|
|
|
try
|
|
{
|
|
string path = LeasesPath();
|
|
|
|
if (File.Exists(path))
|
|
rows = JsonConvert.DeserializeObject<List<LeaseHold>>(File.ReadAllText(path));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Puts($"could not read leases: {Flatten(ex.Message)}");
|
|
}
|
|
|
|
if (rows == null)
|
|
return;
|
|
|
|
int dropped = 0;
|
|
|
|
foreach (LeaseHold h in rows)
|
|
{
|
|
if (h == null || string.IsNullOrEmpty(h.Key))
|
|
continue;
|
|
|
|
if (h.Key != GroupPermissionKey && h.BootId != _bootId)
|
|
{
|
|
dropped++;
|
|
continue;
|
|
}
|
|
|
|
_leases[LeaseSlot(h.Key, h.Target)] = h;
|
|
}
|
|
|
|
if (dropped > 0)
|
|
SaveLeases();
|
|
|
|
Puts($"leases: {_leases.Count} held, {dropped} ended by a restart");
|
|
|
|
// Due now or later; the first tick restores anything whose deadline passed while the
|
|
// server was down.
|
|
EnsureLeaseTimer();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether a lease holds this group permission, for the permission mirror. A pair a
|
|
/// lease has borrowed is the lease's until it ends: a sync neither applies to it nor
|
|
/// reports it foreign.
|
|
/// </summary>
|
|
private LeaseHold LeasedGroupPermission(string group, string perm)
|
|
{
|
|
LeaseHold hold;
|
|
return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold)
|
|
? hold
|
|
: null;
|
|
}
|
|
|
|
/// <summary>What every hold is doing, from the server console.</summary>
|
|
[ConsoleCommand("rg.leases")]
|
|
private void CmdLeases(ConsoleSystem.Arg arg)
|
|
{
|
|
var lines = new List<string>
|
|
{
|
|
$"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " +
|
|
$"held={_leases.Count} bootId={_bootId}"
|
|
};
|
|
|
|
foreach (LeaseHold h in _leases.Values)
|
|
{
|
|
lines.Add(
|
|
$" {LeaseSlot(h.Key, h.Target)} applied={h.Applied} baseline={h.Baseline} " +
|
|
$"remaining={Math.Max(0L, h.DeadlineMs - NowMs()) / 1000L}s run={h.RunId ?? "-"}");
|
|
}
|
|
|
|
lines.Add($"last: {_lastLeaseSummary}");
|
|
arg.ReplyWith(string.Join(Environment.NewLine, lines.ToArray()));
|
|
}
|
|
|
|
// ---- 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<string, object> row, string key)
|
|
{
|
|
object value;
|
|
return row != null && row.TryGetValue(key, out value) && value != null
|
|
? value.ToString()
|
|
: null;
|
|
}
|
|
|
|
private static int IntField(Dictionary<string, object> 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;
|
|
}
|
|
|
|
private static long LongField(Dictionary<string, object> row, string key)
|
|
{
|
|
object value;
|
|
|
|
if (row == null || !row.TryGetValue(key, out value) || value == null)
|
|
return 0L;
|
|
|
|
long parsed;
|
|
return long.TryParse(value.ToString(), out parsed) ? parsed : 0L;
|
|
}
|
|
|
|
/// <summary>The objects in an array field, skipping anything that is not one.</summary>
|
|
private static IEnumerable<Dictionary<string, object>> Rows(Dictionary<string, object> 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<Dictionary<string, object>>();
|
|
}
|
|
}
|
|
|
|
/// <summary>The strings in an array field.</summary>
|
|
private static IEnumerable<string> Strings(Dictionary<string, object> 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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private static string Lower(string value)
|
|
{
|
|
return value == null ? null : value.Trim().ToLowerInvariant();
|
|
}
|
|
|
|
private static HashSet<string> Bucket(Dictionary<string, HashSet<string>> map, string key)
|
|
{
|
|
HashSet<string> bucket;
|
|
|
|
if (!map.TryGetValue(key, out bucket))
|
|
{
|
|
bucket = new HashSet<string>();
|
|
map[key] = bucket;
|
|
}
|
|
|
|
return bucket;
|
|
}
|
|
|
|
// ---- diagnostics ----
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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")}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// What the last permission sync did, from the server console.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Where this framework keeps configuration, and what the last write from the website
|
|
/// did.
|
|
///
|
|
/// <para>
|
|
/// The root is the interesting half. It is <c>oxide/config</c> on one rig and
|
|
/// <c>carbon/configs</c> 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.
|
|
/// </para>
|
|
/// </summary>
|
|
[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}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Which of this plugin's hooks have actually fired, and how often.
|
|
///
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// A zero is not automatically a fault: <c>OnNewSave</c> fires on wipe day and
|
|
/// <c>OnUserBanned</c> only when somebody is banned. It is a fault when the thing it
|
|
/// names has demonstrably happened.
|
|
/// </para>
|
|
/// </summary>
|
|
[ConsoleCommand("rg.hooks")]
|
|
private void CmdHooks(ConsoleSystem.Arg arg)
|
|
{
|
|
var fired = new List<string>();
|
|
var silent = new List<string>();
|
|
|
|
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}");
|
|
}
|
|
}
|
|
}
|