diff --git a/overlay.toml b/overlay.toml index cebf872..1b3315f 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,10 +26,10 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 7 — the transport, the read path, identity, the permission mirror, +# Current: 8 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, and a raid frame that names who # lives there (docs/rust-link/PROTOCOL.md §13). -protocol = 7 +protocol = 8 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 4083667..7350e89 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -57,7 +57,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 7; + private const int ProtocolVersion = 8; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -313,6 +313,10 @@ namespace Oxide.Plugins 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(); @@ -358,6 +362,16 @@ namespace Oxide.Plugins /// a season and the site must not lose its history for it. /// public string ServerId = "main"; + + /// + /// Whether the website may borrow values on this server for events (protocol 8, + /// D76). Off until an operator turns it on. 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. + /// + public bool EventsEnabled = false; } protected override void LoadDefaultConfig() @@ -382,12 +396,23 @@ namespace Oxide.Plugins 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 ---- @@ -810,6 +835,21 @@ namespace Oxide.Plugins 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; @@ -2735,6 +2775,14 @@ namespace Oxide.Plugins public readonly List Foreign = new List(); public readonly List Pending = new List(); + /// + /// 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. + /// + public readonly List Leased = new List(); + + public readonly HashSet LeasedSeen = new HashSet(); + /// Permission names already reported unresolved, so each is reported once. public readonly HashSet UnresolvedSeen = new HashSet(); @@ -3084,6 +3132,9 @@ namespace Oxide.Plugins if (!Registered(job, op.Object)) break; + if (DeferToLease(job, op, true)) + break; + if (permission.GroupHasPermission(op.Subject, op.Object)) { job.AlreadyCorrect++; @@ -3158,6 +3209,9 @@ namespace Oxide.Plugins break; case PermOpKind.RetireGroupPermission: + if (DeferToLease(job, op, false)) + break; + if (permission.GroupHasPermission(op.Subject, op.Object)) { permission.RevokeGroupPermission(op.Subject, op.Object); @@ -3276,6 +3330,13 @@ namespace Oxide.Plugins 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 { { "kind", "group-permission" }, @@ -3285,6 +3346,43 @@ namespace Oxide.Plugins } } + /// + /// 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. + /// + 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 + { + { "kind", "group-permission" }, + { "subject", Lower(group) }, + { "object", Lower(perm) } + }); + } + /// /// Whether this server has a plugin that registered the name, recording the answer once /// per sync when it has not. @@ -3378,6 +3476,7 @@ namespace Oxide.Plugins 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; @@ -4002,9 +4101,21 @@ namespace Oxide.Plugins } } + // 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, "no reload was asked for", null); + 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; } @@ -4190,6 +4301,66 @@ namespace Oxide.Plugins } } + /// + /// Main thread. If a write touched this plugin's own config, take the one setting that + /// is safe to change live — EventsEnabled — from what was written. Returns + /// whether it did. + /// + private bool ApplyOwnSettings(List 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; + } + /// /// Keeps a copy of a file about to be overwritten, and the nine before it. /// @@ -4573,6 +4744,1037 @@ namespace Oxide.Plugins 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. + + /// + /// 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. + /// + private const long LeaseMaxHoldMs = 7L * 24L * 60L * 60L * 1000L; + + /// How often held deadlines are checked. A lease ends within a second of its time. + private const float LeaseCheckSeconds = 1f; + + /// The wire key of the one lease that is not a convar. + private const string GroupPermissionKey = "group.permission"; + + /// + /// 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. + /// + 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; + } + } + + /// + /// Every convar a lease may name (D74: the game's own convars, nothing Carbon-only). + /// + /// + /// 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). + /// + /// + 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) + }; + + /// + /// One value this plugin is holding. Persisted as it is, so every field is public and + /// plain. + /// + private sealed class LeaseHold + { + public string Key; + + /// Null for a convar; group/permission for a group permission. + public string Target; + + public string Baseline; + public string Applied; + + /// When the hold ends, on this host's clock: taken time plus holdMs. + public long DeadlineMs; + + /// The website's absolute deadline, for display. Never compared against. + public long UntilMs; + + public string RunId; + public string BootId; + public long TakenMs; + + /// + /// 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). + /// + public bool? SiteWants; + } + + /// Holds by . Main thread only. + private Dictionary _leases = new Dictionary(); + + /// Convars by lowered full name, resolved once. Null until first needed. + private Dictionary _convars; + + private Timer _leaseTimer; + + /// The last thing a lease did, for rg.leases. Diagnostics only. + 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; + } + + /// + /// Main thread. The game's convar, or null when this build has no such convar or it + /// is one the plugin refuses to hold. + /// + /// + /// A Saved convar is refused (§27.1). The game writes those to + /// serverauto.cfg, 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 Saved today; this is + /// what keeps a Facepunch change to that from becoming our bug silently. + /// + /// + private ConsoleSystem.Command LeaseConvar(string key, out string refusal) + { + refusal = null; + + if (_convars == null) + { + _convars = new Dictionary(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; + } + + /// + /// Whether two values are the same value, parsed. A convar reads back through + /// float.ToString(), so the tolerance is a float's, not a double's. + /// + 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))); + } + + /// + /// 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. + /// + 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); + } + + /// + /// Main thread. The value a key holds right now, as text — or null with a reason. + /// is set when the target itself no longer exists, which a + /// release treats as nothing left to give back rather than as a failure. + /// + 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; + } + + /// + /// 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. + /// + 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; + } + + /// + /// Main thread. Protocol 8's catalogue: every key this server lends and what it holds + /// now, and every hold in force. + /// + /// + /// Narrowed by key (and target) 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. + /// + /// + private void HandleLeaseList(Dictionary cmd, string reqId) + { + string onlyKey = Lower(StringField(cmd, "key")); + string onlyTarget = StringField(cmd, "target"); + var rows = new List(); + + 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(); + + foreach (LeaseHold h in _leases.Values) + holds.Add(HoldBody(h)); + + Dictionary 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 LeaseRow(string key, string family, double min, double max, string target) + { + var row = new Dictionary + { + { "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 HoldBody(LeaseHold h) + { + var body = new Dictionary + { + { "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; + } + + /// + /// Main thread. Take a value for a while. + /// + /// + /// A key this plugin already holds keeps its ORIGINAL baseline. 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. + /// + /// + private void HandleLeaseApply(Dictionary 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 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); + } + + /// + /// Main thread. Give a value back — compare-and-set. + /// + /// + /// The comparison is against what the LEASE applied, from this plugin's own record + /// when it has one, else from the website's expected. 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. + /// + /// + private void HandleLeaseRelease(Dictionary 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 outcome = Restore(key, target, expected, baseline, hold, "release"); + + if (reqId != null) + outcome["reqId"] = reqId; + + EmitFrame(outcome); + } + + /// + /// 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. + /// + private Dictionary 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 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 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 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 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 RestoredFrame(string key, string target, string value) + { + Dictionary ok = Frame("lease.ok", "reply"); + ok["key"] = key; + + if (!string.IsNullOrEmpty(target)) + ok["target"] = target; + + ok["restored"] = value; + return ok; + } + + /// + /// 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. + /// + private void CheckLeaseDeadlines() + { + if (_leases.Count == 0) + { + StopLeaseTimer(); + return; + } + + long now = NowMs(); + List due = null; + + foreach (LeaseHold h in _leases.Values) + { + if (h.DeadlineMs <= now) + { + if (due == null) + due = new List(); + + due.Add(h); + } + } + + if (due == null) + return; + + foreach (LeaseHold h in due) + Expire(h); + } + + private void Expire(LeaseHold hold) + { + Dictionary 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 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 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"); + } + + /// + /// 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. + /// + private void SaveLeases() + { + try + { + string path = LeasesPath(); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var rows = new List(_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)}"); + } + } + + /// + /// Main thread, once per load, with the world available. Re-arms what is still owed. + /// + /// + /// 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. + /// + /// + private void LoadLeases() + { + _leases = new Dictionary(); + + List rows = null; + + try + { + string path = LeasesPath(); + + if (File.Exists(path)) + rows = JsonConvert.DeserializeObject>(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(); + } + + /// + /// 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. + /// + private LeaseHold LeasedGroupPermission(string group, string perm) + { + LeaseHold hold; + return _leases.TryGetValue(LeaseSlot(GroupPermissionKey, Lower(group) + "/" + Lower(perm)), out hold) + ? hold + : null; + } + + /// What every hold is doing, from the server console. + [ConsoleCommand("rg.leases")] + private void CmdLeases(ConsoleSystem.Arg arg) + { + var lines = new List + { + $"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 @@ -4598,6 +5800,17 @@ namespace Oxide.Plugins return int.TryParse(value.ToString(), out parsed) ? parsed : 0; } + private static long LongField(Dictionary 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; + } + /// The objects in an array field, skipping anything that is not one. private static IEnumerable> Rows(Dictionary row, string key) {