namespace Aeshnidae.RemoteConsole; /// /// A console for a server that has none. /// /// Aeshnidae runs under systemd with ACE_NONINTERACTIVE_CONSOLE=true, so ACE never /// starts its command prompt and there is no stdin to type into. Until now the only /// way to run a console command was to log in as a Developer and type it in chat - /// which meant every content push ended with "and now YOU run this in game", and the /// pipeline could never be finished by a script. /// /// This watches a directory. Drop a file of commands in it, one per line, and they are /// executed exactly the way CommandManager.CommandThread would have executed them from /// the keyboard: ParseCommand, GetCommandHandler with a null session, Invoke. Nothing /// is reimplemented; the same handlers, the same access rules. A null session means /// commands flagged RequiresWorld are refused, as they are on the real console. /// /// Output goes where console output always went - the log, which systemd captures in /// the journal. The receipt written beside the command file carries the start and end /// timestamps precisely so a caller can pull that slice of the journal and read what /// the command said. Capturing the output in-process was considered and rejected: it /// would need a log4net appender, a dependency this mod does not otherwise have, to /// reproduce something journalctl already does. /// /// Security is the directory. Anyone who can write a file there can run any console /// command, so it lives under the mod folder, owned by the account the server runs /// as, and is reachable only by someone who already has ssh to the box. That is the /// boundary that already protects everything else. /// public static class Inbox { private static Timer? _timer; private static string _dir = ""; private static int _busy; public static string Directory => _dir; /// /// Whether the command table has been built yet. See Poll. /// /// Checks for one of ACE's OWN commands, not this mod's. ModContainer registers a /// mod's commands the moment the mod is enabled, which is near the start of /// startup - so "is remoteconsole registered" is true ten seconds before "mod" or /// "clearcache" exist, and a gate on it lets files through into an empty table. /// "acecommands" lives in ACE.Server and is only added by CommandManager.Initialize, /// which is the event actually being waited for. /// public static bool Ready { get { try { return CommandManager.GetCommandByName("acecommands").Any(); } catch { return false; } } } public static void Start() { var configured = Mod.Settings.InboxDirectory; _dir = Path.IsPathRooted(configured) ? configured : Path.Combine(Mod.ModPath, configured); System.IO.Directory.CreateDirectory(_dir); var period = TimeSpan.FromSeconds(Math.Max(0.25, Mod.Settings.PollSeconds)); _timer = new Timer(_ => Poll(), null, period, period); ModManager.Log($"[{Mod.Name}] watching {_dir} - drop .cmd there, one command per line"); } public static void Stop() { _timer?.Dispose(); _timer = null; } /// /// One pass over the inbox. Re-entrancy guarded, because a command can legitimately /// take longer than the poll interval (a landblock walk, say) and a second timer /// tick must not start executing the next file on top of it. /// private static void Poll() { if (Interlocked.Exchange(ref _busy, 1) == 1) return; try { // ACE registers commands LAST in startup - CommandManager.Initialize runs // after every landblock and every mod is up - while this mod starts polling // the moment it is enabled, near the beginning. A file dropped during those // seconds would be claimed and every line refused as "no such command". So // nothing is claimed until this mod's own command is registered, which is // proof that both CommandManager.Initialize and ModManager.RegisterCommands // have run. Files simply wait in the inbox until then. if (!Ready) return; // Name order is execution order - a caller that needs sequencing names // the files accordingly. foreach (var path in System.IO.Directory.GetFiles(_dir, "*.cmd").OrderBy(p => p, StringComparer.Ordinal)) Run(path); Sweep(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] inbox poll failed: {ex}", ModManager.LogLevel.Error); } finally { Interlocked.Exchange(ref _busy, 0); } } private static void Run(string cmdPath) { // Claim it first. A rename is atomic, so a second poll (or a second server, // if that ever happens) cannot pick the same file up. var running = Path.ChangeExtension(cmdPath, ".running"); try { File.Move(cmdPath, running); } catch (IOException) { return; // somebody else got it, or it vanished } var receipt = new StringBuilder(); var started = DateTime.UtcNow; receipt.AppendLine($"started {started:O}"); string[] lines; try { lines = File.ReadAllLines(running); } catch (Exception ex) { receipt.AppendLine($"unreadable: {ex.Message}"); Finish(running, receipt, started); return; } foreach (var raw in lines) { var line = raw.Trim(); if (line.Length == 0 || line.StartsWith('#')) continue; receipt.AppendLine($"> {line}"); receipt.AppendLine($" {Execute(line)}"); } Finish(running, receipt, started); } /// /// CommandManager.CommandThread, one line at a time. Every branch that method /// takes is taken here, so a command behaves identically to having been typed. /// private static string Execute(string commandLine) { // Typing a leading slash is muscle memory from chat; the console never wanted // one, so strip it rather than fail on the most likely mistake. if (commandLine.StartsWith('/') || commandLine.StartsWith('@')) commandLine = commandLine[1..]; string command; string[] parameters; try { CommandManager.ParseCommand(commandLine, out command, out parameters); } catch (Exception ex) { return $"could not parse: {ex.Message}"; } CommandHandlerResponse response; CommandHandlerInfo? info; try { response = CommandManager.GetCommandHandler(null, command, parameters, out info); } catch (Exception ex) { return $"could not resolve: {ex.Message}"; } if (response != CommandHandlerResponse.Ok || info is null) return response switch { CommandHandlerResponse.InvalidCommand => "no such command", CommandHandlerResponse.NotInWorld => "needs a player in the world - cannot run from the console", CommandHandlerResponse.NotAuthorized => "not authorized", CommandHandlerResponse.NoConsoleInvoke => "this command refuses the console", CommandHandlerResponse.InvalidParameterCount => "wrong number of parameters", _ => $"refused: {response}", }; try { if (info.Attribute.IncludeRaw) parameters = CommandManager.StuffRawIntoParameters(commandLine, command, parameters); ((CommandHandler)info.Handler).Invoke(null, parameters); return "ok"; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] '{commandLine}' threw: {ex}", ModManager.LogLevel.Error); return $"threw {ex.GetType().Name}: {ex.Message}"; } } /// /// Writes the receipt and removes the claim. The receipt is written to a temp name /// and renamed, so a caller polling for .done never reads a half-written one. /// private static void Finish(string running, StringBuilder receipt, DateTime started) { var finished = DateTime.UtcNow; receipt.AppendLine($"finished {finished:O}"); receipt.AppendLine($"journal: journalctl -u ace --since \"{started:yyyy-MM-dd HH:mm:ss}\" --until \"{finished.AddSeconds(1):yyyy-MM-dd HH:mm:ss}\" --utc"); var done = Path.ChangeExtension(running, ".done"); var temp = done + ".tmp"; try { File.WriteAllText(temp, receipt.ToString()); File.Move(temp, done, overwrite: true); File.Delete(running); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not write receipt for {Path.GetFileName(running)}: {ex.Message}", ModManager.LogLevel.Warn); } } /// Old receipts are noise. Anything else in the directory is left alone. private static void Sweep() { var minutes = Mod.Settings.ReceiptLifetimeMinutes; if (minutes <= 0) return; var cutoff = DateTime.UtcNow.AddMinutes(-minutes); foreach (var path in System.IO.Directory.GetFiles(_dir, "*.done")) { try { if (File.GetLastWriteTimeUtc(path) < cutoff) File.Delete(path); } catch { // a receipt that will not delete is not worth a log line } } } }