diff --git a/Content.Server/Administration/Commands/ChangeCvarCommand.cs b/Content.Server/Administration/Commands/ChangeCvarCommand.cs new file mode 100644 index 0000000000..984c9c1077 --- /dev/null +++ b/Content.Server/Administration/Commands/ChangeCvarCommand.cs @@ -0,0 +1,215 @@ +using System.Linq; +using Content.Server.Administration.Logs; +using Content.Server.Administration.Managers; +using Content.Shared.Administration; +using Content.Shared.Database; +using Robust.Shared.Configuration; +using Robust.Shared.Console; + +namespace Content.Server.Administration.Commands; + +/// +/// Allows admins to change certain CVars. This is different than the "cvar" command which is host only and can change any CVar. +/// +/// +/// Possible todo for future, store default values for cvars, and allow resetting to default. +/// +[AnyCommand] +public sealed class ChangeCvarCommand : IConsoleCommand +{ + [Dependency] private readonly IConfigurationManager _configurationManager = default!; + [Dependency] private readonly IAdminLogManager _adminLogManager = default!; + [Dependency] private readonly CVarControlManager _cVarControlManager = default!; + + /// + /// Searches the list of cvars for a cvar that matches the search string. + /// + private void SearchCVars(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length < 2) + { + shell.WriteLine(Loc.GetString("cmd-changecvar-search-no-arguments")); + return; + } + + var cvars = _cVarControlManager.GetAllRunnableCvars(shell); + + var matches = cvars + .Where(c => + c.Name.Contains(args[1], StringComparison.OrdinalIgnoreCase) + || c.ShortHelp?.Contains(args[1], StringComparison.OrdinalIgnoreCase) == true + || c.LongHelp?.Contains(args[1], StringComparison.OrdinalIgnoreCase) == true + ) // Might be very slow and stupid, but eh. + .ToList(); + + if (matches.Count == 0) + { + shell.WriteLine(Loc.GetString("cmd-changecvar-search-no-matches")); + return; + } + + shell.WriteLine(Loc.GetString("cmd-changecvar-search-matches", ("count", matches.Count))); + shell.WriteLine(string.Join("\n", matches.Select(FormatCVarFullHelp))); + } + + /// + /// Formats a CVar into a string for display. + /// + private string FormatCVarFullHelp(ChangableCVar cvar) + { + if (cvar.LongHelp != null && cvar.ShortHelp != null) + { + return $"{cvar.Name} - {cvar.LongHelp}"; + } + + // There is no help, no one is coming. We are all doomed. + return cvar.Name; + } + + public string Command => "changecvar"; + public string Description { get; } = Loc.GetString("cmd-changecvar-desc"); + public string Help { get; } = Loc.GetString("cmd-changecvar-help"); + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length == 0) + { + shell.WriteLine(Loc.GetString("cmd-changecvar-no-arguments")); + return; + } + + var cvars = _cVarControlManager.GetAllRunnableCvars(shell); + + var cvar = args[0]; + if (cvar == "?") + { + if (cvars.Count == 0) + { + shell.WriteLine(Loc.GetString("cmd-changecvar-no-cvars")); + return; + } + + shell.WriteLine(Loc.GetString("cmd-changecvar-available-cvars")); + shell.WriteLine(string.Join("\n", cvars.Select(FormatCVarFullHelp))); + return; + } + + if (cvar == "search") + { + SearchCVars(shell, argStr, args); + return; + } + + if (!_configurationManager.IsCVarRegistered(cvar)) // Might be a redunat check with the if statement below. + { + shell.WriteLine(Loc.GetString("cmd-changecvar-cvar-not-registered", ("cvar", cvar))); + return; + } + + if (cvars.All(c => c.Name != cvar)) + { + shell.WriteLine(Loc.GetString("cmd-changecvar-cvar-not-allowed")); + return; + } + + if (args.Length == 1) + { + var value = _configurationManager.GetCVar(cvar); + shell.WriteLine(value.ToString()!); + } + else + { + var value = args[1]; + var type = _configurationManager.GetCVarType(cvar); + try + { + var parsed = CVarCommandUtil.ParseObject(type, value); + // Value check, is it in the min/max range? + var control = _cVarControlManager.GetCVar(cvar)!.Control; // Null check is done above. + var allowed = true; + if (control is { Min: not null, Max: not null }) + { + switch (parsed) // This looks bad, and im not sorry. + { + case int intVal: + { + if (intVal < (int)control.Min || intVal > (int)control.Max) + { + allowed = false; + } + + break; + } + case float floatVal: + { + if (floatVal < (float)control.Min || floatVal > (float)control.Max) + { + allowed = false; + } + + break; + } + case long longVal: + { + if (longVal < (long)control.Min || longVal > (long)control.Max) + { + allowed = false; + } + + break; + } + case ushort ushortVal: + { + if (ushortVal < (ushort)control.Min || ushortVal > (ushort)control.Max) + { + allowed = false; + } + + break; + } + } + } + + if (!allowed) + { + shell.WriteError(Loc.GetString("cmd-changecvar-value-out-of-range", + ("min", control.Min ?? "-∞"), + ("max", control.Max ?? "∞"))); + return; + } + + var oldValue = _configurationManager.GetCVar(cvar); + _configurationManager.SetCVar(cvar, parsed); + _adminLogManager.Add(LogType.AdminCommands, + LogImpact.High, + $"{shell.Player!.Name} ({shell.Player!.UserId}) changed CVAR {cvar} from {oldValue.ToString()} to {parsed.ToString()}" + ); + + shell.WriteLine(Loc.GetString("cmd-changecvar-success", ("cvar", cvar), ("old", oldValue), ("value", parsed))); + } + catch (FormatException) + { + shell.WriteError(Loc.GetString("cmd-cvar-parse-error", ("type", type))); + } + } + } + + public CompletionResult GetCompletion(IConsoleShell shell, string[] args) + { + var cvars = _cVarControlManager.GetAllRunnableCvars(shell); + + if (args.Length == 1) + { + return CompletionResult.FromHintOptions( + cvars + .Select(c => new CompletionOption(c.Name, c.ShortHelp ?? c.Name)), + Loc.GetString("cmd-changecvar-arg-name")); + } + + var cvar = args[0]; + if (!_configurationManager.IsCVarRegistered(cvar)) + return CompletionResult.Empty; + + var type = _configurationManager.GetCVarType(cvar); + return CompletionResult.FromHint($"<{type.Name}>"); + } +} diff --git a/Content.Server/Administration/Managers/CVarControlManager.cs b/Content.Server/Administration/Managers/CVarControlManager.cs new file mode 100644 index 0000000000..4d15190551 --- /dev/null +++ b/Content.Server/Administration/Managers/CVarControlManager.cs @@ -0,0 +1,125 @@ +using System.Linq; +using System.Reflection; +using Content.Shared.CCVar.CVarAccess; +using Robust.Shared.Configuration; +using Robust.Shared.Console; +using Robust.Shared.Player; +using Robust.Shared.Reflection; + +namespace Content.Server.Administration.Managers; + +/// +/// Manages the control of CVars via the attribute. +/// +public sealed class CVarControlManager : IPostInjectInit +{ + [Dependency] private readonly IReflectionManager _reflectionManager = default!; + [Dependency] private readonly IAdminManager _adminManager = default!; + [Dependency] private readonly ILocalizationManager _localizationManager = default!; + [Dependency] private readonly ILogManager _logger = default!; + + private readonly List _changableCvars = new(); + private ISawmill _sawmill = default!; + + void IPostInjectInit.PostInject() + { + _sawmill = _logger.GetSawmill("cvarcontrol"); + } + + public void Initialize() + { + RegisterCVars(); + } + + private void RegisterCVars() + { + if (_changableCvars.Count != 0) + { + _sawmill.Warning("CVars already registered, overwriting."); + _changableCvars.Clear(); + } + + var validCvarsDefs = _reflectionManager.FindTypesWithAttribute(); + + foreach (var type in validCvarsDefs) + { + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.FlattenHierarchy)) + { + var allowed = field.GetCustomAttribute(); + if (allowed == null) + { + continue; + } + + var cvarDef = (CVarDef)field.GetValue(null)!; + _changableCvars.Add(new ChangableCVar(cvarDef.Name, allowed, _localizationManager)); + } + } + + _sawmill.Info($"Registered {_changableCvars.Count} CVars."); + } + + /// + /// Gets all CVars that the player can change. + /// + public List GetAllRunnableCvars(IConsoleShell shell) + { + // Not a player, running as server. We COULD return all cvars, + // but a check later down the line will prevent it from anyways. Use the "cvar" command instead. + if (shell.Player == null) + return []; + + return GetAllRunnableCvars(shell.Player); + } + + public List GetAllRunnableCvars(ICommonSession session) + { + var adminData = _adminManager.GetAdminData(session); + if (adminData == null) + return []; // Not an admin + + return _changableCvars + .Where(cvar => adminData.HasFlag(cvar.Control.AdminFlags)) + .ToList(); + } + + public ChangableCVar? GetCVar(string name) + { + return _changableCvars.FirstOrDefault(cvar => cvar.Name == name); + } +} + +public sealed class ChangableCVar +{ + private const string LocPrefix = "changecvar"; + + public string Name { get; } + + // Holding a reference to the attribute might be skrunkly? Not sure how much mem it eats up. + public CVarControl Control { get; } + + public string? ShortHelp; + public string? LongHelp; + + public ChangableCVar(string name, CVarControl control, ILocalizationManager loc) + { + Name = name; + Control = control; + + if (loc.TryGetString($"{LocPrefix}-simple-{name.Replace('.', '_')}", out var simple)) + { + ShortHelp = simple; + } + + if (loc.TryGetString($"{LocPrefix}-full-{name.Replace('.', '_')}", out var longHelp)) + { + LongHelp = longHelp; + } + + // If one is set and the other is not, we throw + if (ShortHelp == null && LongHelp != null || ShortHelp != null && LongHelp == null) + { + throw new InvalidOperationException("Short and long help must both be set or both be null."); + } + } +} diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index b9c20942a0..9d5cb0b10e 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -153,6 +153,7 @@ namespace Content.Server.Entry IoCManager.Resolve().Initialize(); IoCManager.Resolve().PostInit(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); } } diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 50b248a9ea..fb3ba193b8 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -76,6 +76,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Shared.Database/LogType.cs b/Content.Shared.Database/LogType.cs index c2896b33be..5ebb100daf 100644 --- a/Content.Shared.Database/LogType.cs +++ b/Content.Shared.Database/LogType.cs @@ -449,4 +449,9 @@ public enum LogType /// An atmos networked device (such as a vent or pump) has had its settings changed, usually through an air alarm /// AtmosDeviceSetting = 97, + + /// + /// Commands related to admemes. Stuff like config changes, etc. + /// + AdminCommands = 98, } diff --git a/Content.Shared/CCVar/CCVars.cs b/Content.Shared/CCVar/CCVars.cs index 316d9b8690..d68ab16874 100644 --- a/Content.Shared/CCVar/CCVars.cs +++ b/Content.Shared/CCVar/CCVars.cs @@ -1,3 +1,5 @@ +using Content.Shared.Administration; +using Content.Shared.CCVar.CVarAccess; using Robust.Shared; using Robust.Shared.Configuration; @@ -14,6 +16,16 @@ public sealed partial class CCVars : CVars { // Only debug stuff lives here. +#if DEBUG + [CVarControl(AdminFlags.Debug)] + public static readonly CVarDef DebugTestCVar = + CVarDef.Create("debug.test_cvar", "default", CVar.SERVER); + + [CVarControl(AdminFlags.Debug)] + public static readonly CVarDef DebugTestCVar2 = + CVarDef.Create("debug.test_cvar2", 123.42069f, CVar.SERVER); +#endif + /// /// A simple toggle to test OptionsVisualizerComponent. /// diff --git a/Content.Shared/CCVar/CVarAccess/CVarControl.cs b/Content.Shared/CCVar/CVarAccess/CVarControl.cs new file mode 100644 index 0000000000..799738cf3d --- /dev/null +++ b/Content.Shared/CCVar/CVarAccess/CVarControl.cs @@ -0,0 +1,38 @@ +using Content.Shared.Administration; +using Robust.Shared.Reflection; + +namespace Content.Shared.CCVar.CVarAccess; + +/// +/// Manages what admin flags can change the cvar value. With optional mins and maxes. +/// +[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] +[Reflect(discoverable: true)] +public sealed class CVarControl : Attribute +{ + public AdminFlags AdminFlags { get; } + public object? Min { get; } + public object? Max { get; } + + public CVarControl(AdminFlags adminFlags, object? min = null, object? max = null, string? helpText = null) + { + AdminFlags = adminFlags; + Min = min; + Max = max; + + // Not actually sure if its a good idea to throw exceptions in attributes. + + if (min != null && max != null) + { + if (min.GetType() != max.GetType()) + { + throw new ArgumentException("Min and max must be of the same type."); + } + } + + if (min == null && max != null || min != null && max == null) + { + throw new ArgumentException("Min and max must both be null or both be set."); + } + } +} diff --git a/Resources/Locale/en-US/administration/commands/change-cvar-command.ftl b/Resources/Locale/en-US/administration/commands/change-cvar-command.ftl new file mode 100644 index 0000000000..b58339e57e --- /dev/null +++ b/Resources/Locale/en-US/administration/commands/change-cvar-command.ftl @@ -0,0 +1,15 @@ +cmd-changecvar-no-arguments = You must specify a cvar. +cmd-changecvar-cvar-not-registered = The cvar {$cvar} is not registered. +cmd-changecvar-cvar-not-allowed = You cannot change this cvar. +cmd-changecvar-value-out-of-range = The value is out of range. The range is {$min} to {$max}. +cmd-changecvar-desc = Change a cvar value. +cmd-changecvar-help = Usage: changecvar +cmd-changecvar-available-cvars = Listing available cvars: +cmd-changecvar-no-cvars = No cvars found that you are allowed to change. +cmd-changecvar-success = CVar {$cvar} changed from "{$old}" to "{$value}". + +cmd-changecvar-search-no-arguments = You must specify a search term. +cmd-changecvar-search-no-matches = No cvars found matching the search term. +cmd-changecvar-search-matches = Found {$count} cvars matching the search term: + +cmd-changecvar-arg-name = diff --git a/Resources/Locale/en-US/cvar/cvar-help.ftl b/Resources/Locale/en-US/cvar/cvar-help.ftl new file mode 100644 index 0000000000..a0738fb5cb --- /dev/null +++ b/Resources/Locale/en-US/cvar/cvar-help.ftl @@ -0,0 +1,2 @@ +changecvar-simple-debug_test_cvar = Does nothing. +changecvar-full-debug_test_cvar = Just a simple testing cvar. Does nothing.