diff --git a/OpenSim/Framework/Console/RemoteConsole.cs b/OpenSim/Framework/Console/RemoteConsole.cs index 8ad7b0daa9..9049b4b801 100644 --- a/OpenSim/Framework/Console/RemoteConsole.cs +++ b/OpenSim/Framework/Console/RemoteConsole.cs @@ -34,6 +34,7 @@ using System.Reflection; using System.Text; using System.Text.RegularExpressions; using System.Threading; +using System.Timers; using OpenMetaverse; using Nini.Config; using OpenSim.Framework.Servers.HttpServer; @@ -41,90 +42,232 @@ using log4net; namespace OpenSim.Framework.Console { - public class ConsoleConnection - { - public int last; - public long lastLineSeen; - public bool newConnection = true; - } - // A console that uses REST interfaces // public class RemoteConsole : CommandConsole { - private IHttpServer m_Server = null; - private IConfigSource m_Config = null; + // Connection specific data, indexed by a session ID + // we create when a client connects. + protected class ConsoleConnection + { + // Last activity from the client + public int last; - private List m_Scrollback = new List(); - private ManualResetEvent m_DataEvent = new ManualResetEvent(false); - private List m_InputData = new List(); - private long m_LineNumber = 0; - private Dictionary m_Connections = + // Last line of scrollback posted to this client + public long lastLineSeen; + + // True if this is a new connection, e.g. has never + // displayed a prompt to the user. + public bool newConnection = true; + } + + // A line in the scrollback buffer. + protected class ScrollbackEntry + { + // The line number of this entry + public long lineNumber; + + // The text to send to the client + public string text; + + // The level this should be logged as. Omitted for + // prompts and input echo. + public string level; + + // True if the text above is a prompt, e.g. the + // client should turn on the cursor / accept input + public bool isPrompt; + + // True if the requested input is a command. A + // client may offer help or validate input if + // this is set. If false, input should be sent + // as typed. + public bool isCommand; + + // True if this text represents a line of text that + // was input in response to a prompt. A client should + // turn off the cursor and refrain from sending commands + // until a new prompt is received. + public bool isInput; + } + + // Data that is relevant to all connections + + // The scrollback buffer + protected List m_Scrollback = new List(); + + // Monotonously incrementing line number. This may eventually + // wrap. No provision is made for that case because 64 bits + // is a long, long time. + protected long m_lineNumber = 0; + + // These two variables allow us to send the correct + // information about the prompt status to the client, + // irrespective of what may have run off the top of the + // scrollback buffer; + protected bool m_expectingInput = false; + protected bool m_expectingCommand = true; + protected string m_lastPromptUsed; + + // This is the list of things received from clients. + // Note: Race conditions can happen. If a client sends + // something while nothing is expected, it will be + // intepreted as input to the next prompt. For + // commands this is largely correct. For other prompts, + // YMMV. + // TODO: Find a better way to fix this + protected List m_InputData = new List(); + + // Event to allow ReadLine to wait synchronously even though + // everthing else is asynchronous here. + protected ManualResetEvent m_DataEvent = new ManualResetEvent(false); + + // The list of sessions we maintain. Unlike other console types, + // multiple users on the same console are explicitly allowed. + protected Dictionary m_Connections = new Dictionary(); - private string m_UserName = String.Empty; - private string m_Password = String.Empty; - private string m_AllowedOrigin = String.Empty; + + // Timer to control expiration of sessions that have been + // disconnected. + protected System.Timers.Timer m_expireTimer = new System.Timers.Timer(5000); + + // The less interesting stuff that makes the actual server + // work. + protected IHttpServer m_Server = null; + protected IConfigSource m_Config = null; + + protected string m_UserName = String.Empty; + protected string m_Password = String.Empty; + protected string m_AllowedOrigin = String.Empty; + public RemoteConsole(string defaultPrompt) : base(defaultPrompt) { + // There is something wrong with this architecture. + // A prompt is sent on every single input, so why have this? + // TODO: Investigate and fix. + m_lastPromptUsed = defaultPrompt; + + // Start expiration of sesssions. + m_expireTimer.Elapsed += DoExpire; + m_expireTimer.Start(); } public void ReadConfig(IConfigSource config) { m_Config = config; + // We're pulling this from the 'Network' section for legacy + // compatibility. However, this is so essentially insecure + // that TLS and client certs should be used instead of + // a username / password. IConfig netConfig = m_Config.Configs["Network"]; + if (netConfig == null) return; + // Get the username and password. m_UserName = netConfig.GetString("ConsoleUser", String.Empty); m_Password = netConfig.GetString("ConsolePass", String.Empty); + + // Woefully underdocumented, this is what makes javascript + // console clients work. Set to "*" for anywhere or (better) + // to specific addresses. m_AllowedOrigin = netConfig.GetString("ConsoleAllowedOrigin", String.Empty); } public void SetServer(IHttpServer server) { + // This is called by the framework to give us the server + // instance (means: port) to work with. m_Server = server; + // Add our handlers m_Server.AddHTTPHandler("/StartSession/", HandleHttpStartSession); m_Server.AddHTTPHandler("/CloseSession/", HandleHttpCloseSession); m_Server.AddHTTPHandler("/SessionCommand/", HandleHttpSessionCommand); } public override void Output(string text, string level) + { + Output(text, level, false, false, false); + } + + protected void Output(string text, string level, bool isPrompt, bool isCommand, bool isInput) { + // Increment the line number. It was 0 and they start at 1 + // so we need to pre-increment. + m_lineNumber++; + + // Create and populate the new entry. + ScrollbackEntry newEntry = new ScrollbackEntry(); + + newEntry.lineNumber = m_lineNumber; + newEntry.text = text; + newEntry.level = level; + newEntry.isPrompt = isPrompt; + newEntry.isCommand = isCommand; + newEntry.isInput = isInput; + + // Add a line to the scrollback. In some cases, that may not + // actually be a line of text. lock (m_Scrollback) { + // Prune the scrollback to the length se send as connect + // burst to give the user some context. while (m_Scrollback.Count >= 1000) m_Scrollback.RemoveAt(0); - m_LineNumber++; - m_Scrollback.Add(String.Format("{0}", m_LineNumber)+":"+level+":"+text); + + m_Scrollback.Add(newEntry); } + + // Let the rest of the system know we have output something. FireOnOutput(text.Trim()); + + // Also display it for debugging. System.Console.WriteLine(text.Trim()); } public override void Output(string text) { - Output(text, "normal"); + // Output plain (non-logging style) text. + Output(text, String.Empty, false, false, false); } public override string ReadLine(string p, bool isCommand, bool e) { - if (isCommand) - Output("+++"+p); - else - Output("-++"+p); + // Output the prompt an prepare to wait. This + // is called on a dedicated console thread and + // needs to be synchronous. Old architecture but + // not worth upgrading. + if (isCommand) + { + m_expectingInput = true; + m_expectingCommand = true; + Output(p, String.Empty, true, true, false); + m_lastPromptUsed = p; + } + else + { + m_expectingInput = true; + Output(p, String.Empty, true, false, false); + } + + // Here is where we wait for the user to input something. m_DataEvent.WaitOne(); string cmdinput; + // Check for empty input. Read input if not empty. lock (m_InputData) { if (m_InputData.Count == 0) { m_DataEvent.Reset(); + m_expectingInput = false; + m_expectingCommand = false; + return ""; } @@ -135,8 +278,19 @@ namespace OpenSim.Framework.Console } + m_expectingInput = false; + m_expectingCommand = false; + + // Echo to all the other users what we have done. This + // will also go to ourselves. + Output (cmdinput, String.Empty, false, false, true); + + // If this is a command, we need to resolve and execute it. if (isCommand) { + // This call will actually execute the command and create + // any output associated with it. The core just gets an + // empty string so it will call again immediately. string[] cmd = Commands.Resolve(Parser.Parse(cmdinput)); if (cmd.Length != 0) @@ -151,18 +305,23 @@ namespace OpenSim.Framework.Console return String.Empty; } } + + // Return the raw input string if not a command. return cmdinput; } - private Hashtable CheckOrigin(Hashtable result) + // Very simplistic static access control header. + protected Hashtable CheckOrigin(Hashtable result) { if (!string.IsNullOrEmpty(m_AllowedOrigin)) result["access_control_allow_origin"] = m_AllowedOrigin; + return result; } + /* TODO: Figure out how PollServiceHTTPHandler can access the request headers * in order to use m_AllowedOrigin as a regular expression - private Hashtable CheckOrigin(Hashtable headers, Hashtable result) + protected Hashtable CheckOrigin(Hashtable headers, Hashtable result) { if (!string.IsNullOrEmpty(m_AllowedOrigin)) { @@ -177,18 +336,23 @@ namespace OpenSim.Framework.Console } */ - private void DoExpire() + protected void DoExpire(Object sender, ElapsedEventArgs e) { + // Iterate the list of console connections and find those we + // haven't heard from for longer then the longpoll interval. + // Remove them. List expired = new List(); lock (m_Connections) { + // Mark the expired ones foreach (KeyValuePair kvp in m_Connections) { if (System.Environment.TickCount - kvp.Value.last > 500000) expired.Add(kvp.Key); } + // Delete them foreach (UUID id in expired) { m_Connections.Remove(id); @@ -197,10 +361,10 @@ namespace OpenSim.Framework.Console } } - private Hashtable HandleHttpStartSession(Hashtable request) + // Start a new session. + protected Hashtable HandleHttpStartSession(Hashtable request) { - DoExpire(); - + // The login is in the form of a http form post Hashtable post = DecodePostString(request["body"].ToString()); Hashtable reply = new Hashtable(); @@ -208,6 +372,7 @@ namespace OpenSim.Framework.Console reply["int_response_code"] = 401; reply["content_type"] = "text/plain"; + // Check user name and password if (m_UserName == String.Empty) return reply; @@ -220,22 +385,28 @@ namespace OpenSim.Framework.Console return reply; } + // Set up the new console connection record ConsoleConnection c = new ConsoleConnection(); c.last = System.Environment.TickCount; c.lastLineSeen = 0; + // Assign session ID UUID sessionID = UUID.Random(); + // Add connection to list. lock (m_Connections) { m_Connections[sessionID] = c; } + // This call is a CAP. The URL is the authentication. string uri = "/ReadResponses/" + sessionID.ToString() + "/"; m_Server.AddPollServiceHTTPHandler( uri, new PollServiceEventArgs(null, uri, HasEvents, GetEvents, NoEvents, sessionID,25000)); // 25 secs timeout + // Our reply is an XML document. + // TODO: Change this to Linq.Xml XmlDocument xmldoc = new XmlDocument(); XmlNode xmlnode = xmldoc.CreateNode(XmlNodeType.XmlDeclaration, "", ""); @@ -252,12 +423,13 @@ namespace OpenSim.Framework.Console rootElement.AppendChild(id); XmlElement prompt = xmldoc.CreateElement("", "Prompt", ""); - prompt.AppendChild(xmldoc.CreateTextNode(DefaultPrompt)); + prompt.AppendChild(xmldoc.CreateTextNode(m_lastPromptUsed)); rootElement.AppendChild(prompt); rootElement.AppendChild(MainConsole.Instance.Commands.GetXml(xmldoc)); + // Set up the response and check origin reply["str_response_string"] = xmldoc.InnerXml; reply["int_response_code"] = 200; reply["content_type"] = "text/xml"; @@ -266,10 +438,9 @@ namespace OpenSim.Framework.Console return reply; } - private Hashtable HandleHttpCloseSession(Hashtable request) + // Client closes session. Clean up. + protected Hashtable HandleHttpCloseSession(Hashtable request) { - DoExpire(); - Hashtable post = DecodePostString(request["body"].ToString()); Hashtable reply = new Hashtable(); @@ -316,10 +487,9 @@ namespace OpenSim.Framework.Console return reply; } - private Hashtable HandleHttpSessionCommand(Hashtable request) + // Command received from the client. + protected Hashtable HandleHttpSessionCommand(Hashtable request) { - DoExpire(); - Hashtable post = DecodePostString(request["body"].ToString()); Hashtable reply = new Hashtable(); @@ -327,6 +497,7 @@ namespace OpenSim.Framework.Console reply["int_response_code"] = 404; reply["content_type"] = "text/plain"; + // Check the ID if (post["ID"] == null) return reply; @@ -334,21 +505,25 @@ namespace OpenSim.Framework.Console if (!UUID.TryParse(post["ID"].ToString(), out id)) return reply; + // Find the connection for that ID. lock (m_Connections) { if (!m_Connections.ContainsKey(id)) return reply; } + // Empty post. Just error out. if (post["COMMAND"] == null) return reply; + // Place the input data in the buffer. lock (m_InputData) { m_DataEvent.Set(); m_InputData.Add(post["COMMAND"].ToString()); } + // Create the XML reply document. XmlDocument xmldoc = new XmlDocument(); XmlNode xmlnode = xmldoc.CreateNode(XmlNodeType.XmlDeclaration, "", ""); @@ -372,7 +547,8 @@ namespace OpenSim.Framework.Console return reply; } - private Hashtable DecodePostString(string data) + // Decode a HTTP form post to a Hashtable + protected Hashtable DecodePostString(string data) { Hashtable result = new Hashtable(); @@ -396,6 +572,7 @@ namespace OpenSim.Framework.Console return result; } + // Close the CAP receiver for the responses for a given client. public void CloseConnection(UUID id) { try @@ -409,7 +586,9 @@ namespace OpenSim.Framework.Console } } - private bool HasEvents(UUID RequestID, UUID sessionID) + // Check if there is anything to send. Return true if this client has + // lines pending. + protected bool HasEvents(UUID RequestID, UUID sessionID) { ConsoleConnection c = null; @@ -420,13 +599,15 @@ namespace OpenSim.Framework.Console c = m_Connections[sessionID]; } c.last = System.Environment.TickCount; - if (c.lastLineSeen < m_LineNumber) + if (c.lastLineSeen < m_lineNumber) return true; return false; } - private Hashtable GetEvents(UUID RequestID, UUID sessionID) + // Send all pending output to the client. + protected Hashtable GetEvents(UUID RequestID, UUID sessionID) { + // Find the connection that goes with this client. ConsoleConnection c = null; lock (m_Connections) @@ -435,12 +616,15 @@ namespace OpenSim.Framework.Console return NoEvents(RequestID, UUID.Zero); c = m_Connections[sessionID]; } + + // If we have nothing to send, send the no events response. c.last = System.Environment.TickCount; - if (c.lastLineSeen >= m_LineNumber) + if (c.lastLineSeen >= m_lineNumber) return NoEvents(RequestID, UUID.Zero); Hashtable result = new Hashtable(); + // Create the response document. XmlDocument xmldoc = new XmlDocument(); XmlNode xmlnode = xmldoc.CreateNode(XmlNodeType.XmlDeclaration, "", ""); @@ -449,30 +633,53 @@ namespace OpenSim.Framework.Console XmlElement rootElement = xmldoc.CreateElement("", "ConsoleSession", ""); - if (c.newConnection) - { - c.newConnection = false; - Output("+++" + DefaultPrompt); - } + //if (c.newConnection) + //{ + // c.newConnection = false; + // Output("+++" + DefaultPrompt); + //} lock (m_Scrollback) { - long startLine = m_LineNumber - m_Scrollback.Count; + long startLine = m_lineNumber - m_Scrollback.Count; long sendStart = startLine; if (sendStart < c.lastLineSeen) sendStart = c.lastLineSeen; - for (long i = sendStart ; i < m_LineNumber ; i++) + for (long i = sendStart ; i < m_lineNumber ; i++) { + ScrollbackEntry e = m_Scrollback[(int)(i - startLine)]; + XmlElement res = xmldoc.CreateElement("", "Line", ""); - long line = i + 1; - res.SetAttribute("Number", line.ToString()); - res.AppendChild(xmldoc.CreateTextNode(m_Scrollback[(int)(i - startLine)])); + res.SetAttribute("Number", e.lineNumber.ToString()); + res.SetAttribute("Level", e.level); + // Don't include these for the scrollback, we'll send the + // real state later. + if (!c.newConnection) + { + res.SetAttribute("Prompt", e.isPrompt ? "true" : "false"); + res.SetAttribute("Command", e.isCommand ? "true" : "false"); + res.SetAttribute("Input", e.isInput ? "true" : "false"); + } + else if (i == m_lineNumber - 1) // Last line for a new connection + { + res.SetAttribute("Prompt", m_expectingInput ? "true" : "false"); + res.SetAttribute("Command", m_expectingCommand ? "true" : "false"); + res.SetAttribute("Input", (!m_expectingInput) ? "true" : "false"); + } + else + { + res.SetAttribute("Input", e.isInput ? "true" : "false"); + } + + res.AppendChild(xmldoc.CreateTextNode(e.text)); rootElement.AppendChild(res); } } - c.lastLineSeen = m_LineNumber; + + c.lastLineSeen = m_lineNumber; + c.newConnection = false; xmldoc.AppendChild(rootElement); @@ -486,7 +693,9 @@ namespace OpenSim.Framework.Console return result; } - private Hashtable NoEvents(UUID RequestID, UUID id) + // This is really just a no-op. It generates what is sent + // to the client if the poll times out without any events. + protected Hashtable NoEvents(UUID RequestID, UUID id) { Hashtable result = new Hashtable();