using System; using System.Collections.Generic; using System.IO; using System.Net.Sockets; using Cookie = System.Net.Cookie; using HttpStatusCode = System.Net.HttpStatusCode; using IPEndPoint = System.Net.IPEndPoint; using System.Text; using System.Threading; using AllocsFixes.FileCache; using AllocsFixes.NetConnections.Servers.Web.Handlers; using AllocsFixes.NetConnections.Servers.Web.SSE; using SpaceWizards.HttpListener; using UnityEngine; namespace AllocsFixes.NetConnections.Servers.Web { public class Web : IConsoleServer { private const int guestPermissionLevel = 2000; private const string indexPageUrl = "/app"; public static int handlingCount; public static int currentHandlers; public static long totalHandlingTime = 0; private readonly List handlers = new List (); public readonly List webMods = new List (); private readonly ConnectionHandler connectionHandler; private readonly HttpListener listener = new HttpListener (); private readonly Version httpProtocolVersion = new Version(1, 1); public Web (string _modInstancePath) { try { int webPort = GamePrefs.GetInt (EnumUtils.Parse ("ControlPanelPort")); if (webPort < 1 || webPort > 65533) { Log.Out ("Webserver not started (ControlPanelPort not within 1-65533)"); return; } // TODO: Remove once this becomes the default control panel webPort += 2; if (!HttpListener.IsSupported) { Log.Out ("Webserver not started (needs Windows XP SP2, Server 2003 or later or Mono)"); return; } // TODO: Read from config bool useStaticCache = false; string webfilesFolder = _modInstancePath + "/webserver"; string webfilesFolderLegacy = _modInstancePath + "/weblegacy"; connectionHandler = new ConnectionHandler (); RegisterPathHandler ("/", new RewriteHandler ("/files/")); // React virtual routing RegisterPathHandler ("/app", new RewriteHandler ("/files/index.html", true)); // Legacy web page RegisterPathHandler ("/weblegacy", new StaticHandler ( webfilesFolderLegacy, useStaticCache ? (AbstractCache)new SimpleCache () : new DirectAccess (), false) ); // Do mods relatively early as they should not be requested a lot, unlike the later registrations, especially for API and map tiles RegisterWebMods (useStaticCache); RegisterPathHandler ("/session/", new SessionHandler (webfilesFolder, connectionHandler)); RegisterPathHandler ("/userstatus", new UserStatusHandler ()); RegisterPathHandler ("/sse/", new SseHandler ()); RegisterPathHandler ("/files/", new StaticHandler ( webfilesFolder, useStaticCache ? (AbstractCache) new SimpleCache () : new DirectAccess (), false) ); RegisterPathHandler ("/itemicons/", new ItemIconHandler (true)); RegisterPathHandler ("/map/", new StaticHandler ( GameIO.GetSaveGameDir () + "/map", MapRendering.MapRendering.GetTileCache (), false, "web.map") ); RegisterPathHandler ("/api/", new ApiHandler ()); listener.Prefixes.Add ($"http://+:{webPort}/"); // listener.Prefixes.Add ($"http://[::1]:{webPort}/"); listener.Start (); listener.BeginGetContext (HandleRequest, listener); SdtdConsole.Instance.RegisterServer (this); Log.Out ("Started Webserver on " + webPort); } catch (Exception e) { Log.Error ("Error in Web.ctor: "); Log.Exception (e); } } public void RegisterPathHandler (string _urlBasePath, AbsHandler _handler) { foreach (AbsHandler handler in handlers) { if (handler.UrlBasePath == _urlBasePath) { Log.Error ($"Web: Handler for relative path {_urlBasePath} already registerd."); return; } } handlers.Add (_handler); _handler.SetBasePathAndParent (this, _urlBasePath); } private void RegisterWebMods (bool _useStaticCache) { foreach (Mod mod in ModManager.GetLoadedMods ()) { try { string webModPath = mod.Path + "/WebMod"; if (!Directory.Exists (webModPath)) { continue; } try { WebMod webMod = new WebMod (this, mod, _useStaticCache); webMods.Add (webMod); } catch (InvalidDataException e) { Log.Error ($"Could not load webmod from mod {mod.ModInfo.Name.Value}: {e.Message}"); } } catch (Exception e) { Log.Error ("Failed loading web mods from mod " + mod.ModInfo.Name.Value); Log.Exception (e); } } } public void Disconnect () { try { listener.Stop (); listener.Close (); } catch (Exception e) { Log.Out ("Error in Web.Disconnect: " + e); } } public void Shutdown () { foreach (AbsHandler handler in handlers) { handler.Shutdown (); } } public void SendLine (string _line) { connectionHandler.SendLine (_line); } public void SendLog (string _formattedMessage, string _plainMessage, string _trace, LogType _type, DateTime _timestamp, long _uptime) { // Do nothing, handled by LogBuffer internally } public static bool IsSslRedirected (HttpListenerRequest _req) { string proto = _req.Headers ["X-Forwarded-Proto"]; return !string.IsNullOrEmpty (proto) && proto.Equals ("https", StringComparison.OrdinalIgnoreCase); } #if ENABLE_PROFILER private readonly UnityEngine.Profiling.CustomSampler authSampler = UnityEngine.Profiling.CustomSampler.Create ("Auth"); private readonly UnityEngine.Profiling.CustomSampler handlerSampler = UnityEngine.Profiling.CustomSampler.Create ("Handler"); #endif private void HandleRequest (IAsyncResult _result) { HttpListener listenerInstance = (HttpListener)_result.AsyncState; if (!listenerInstance.IsListening) { return; } Interlocked.Increment (ref handlingCount); Interlocked.Increment (ref currentHandlers); #if ENABLE_PROFILER UnityEngine.Profiling.Profiler.BeginThreadProfiling ("AllocsMods", "WebRequest"); HttpListenerContext ctx = listenerInstance.EndGetContext (_result); try { #else HttpListenerContext ctx = listenerInstance.EndGetContext (_result); listenerInstance.BeginGetContext (HandleRequest, listenerInstance); #endif try { HttpListenerRequest request = ctx.Request; HttpListenerResponse response = ctx.Response; response.SendChunked = false; response.ProtocolVersion = httpProtocolVersion; // No game yet -> fail request if (GameManager.Instance.World == null) { response.StatusCode = (int) HttpStatusCode.ServiceUnavailable; return; } if (request.Url == null) { response.StatusCode = (int) HttpStatusCode.BadRequest; return; } #if ENABLE_PROFILER authSampler.Begin (); #endif int permissionLevel = DoAuthentication (request, out WebConnection conn); #if ENABLE_PROFILER authSampler.End (); #endif //Log.Out ("Login status: conn!=null: {0}, permissionlevel: {1}", conn != null, permissionLevel); if (conn != null) { Cookie cookie = new Cookie ("sid", conn.SessionID, "/") { Expired = false, Expires = DateTime.MinValue, HttpOnly = true, Secure = false }; response.AppendCookie (cookie); } string requestPath = request.Url.AbsolutePath; if (requestPath.Length < 2) { response.Redirect (indexPageUrl); return; } ApplyPathHandler (requestPath, request, response, conn, permissionLevel); } catch (IOException e) { if (e.InnerException is SocketException) { Log.Out ("Error in Web.HandleRequest(): Remote host closed connection: " + e.InnerException.Message); } else { Log.Out ("Error (IO) in Web.HandleRequest(): " + e); } } catch (Exception e) { Log.Error ("Error in Web.HandleRequest(): "); Log.Exception (e); } finally { if (!ctx.Response.SendChunked) { ctx.Response.Close (); } Interlocked.Decrement (ref currentHandlers); } #if ENABLE_PROFILER } finally { listenerInstance.BeginGetContext (HandleRequest, listenerInstance); UnityEngine.Profiling.Profiler.EndThreadProfiling (); } #endif } public void ApplyPathHandler (string _requestPath, HttpListenerRequest _req, HttpListenerResponse _resp, WebConnection _con, int _permissionLevel) { for (int i = handlers.Count - 1; i >= 0; i--) { AbsHandler handler = handlers [i]; if (_requestPath.StartsWith (handler.UrlBasePath)) { if (!handler.IsAuthorizedForHandler (_con, _permissionLevel)) { _resp.StatusCode = (int)HttpStatusCode.Forbidden; if (_con != null) { //Log.Out ("Web.HandleRequest: user '{0}' not allowed to access '{1}'", _con.SteamID, handler.ModuleName); } } else { #if ENABLE_PROFILER handlerSampler.Begin (); #endif handler.HandleRequest (_requestPath, _req, _resp, _con, _permissionLevel); #if ENABLE_PROFILER handlerSampler.End (); #endif } return; } } // Not really relevant for non-debugging purposes: //Log.Out ("Error in Web.HandleRequest(): No handler found for path \"" + _requestPath + "\""); _resp.StatusCode = (int) HttpStatusCode.NotFound; } private int DoAuthentication (HttpListenerRequest _req, out WebConnection _con) { _con = null; string sessionId = _req.Cookies ["sid"]?.Value; IPEndPoint reqRemoteEndPoint = _req.RemoteEndPoint; if (reqRemoteEndPoint == null) { Log.Warning ("No RemoteEndPoint on web request"); return guestPermissionLevel; } if (!string.IsNullOrEmpty (sessionId)) { _con = connectionHandler.IsLoggedIn (sessionId, reqRemoteEndPoint.Address); if (_con != null) { return GameManager.Instance.adminTools.GetUserPermissionLevel (_con.UserId); } } string remoteEndpointString = reqRemoteEndPoint.ToString (); if (_req.QueryString ["adminuser"] != null && _req.QueryString ["admintoken"] != null) { WebPermissions.AdminToken admin = WebPermissions.Instance.GetWebAdmin (_req.QueryString ["adminuser"], _req.QueryString ["admintoken"]); if (admin != null) { return admin.permissionLevel; } Log.Warning ("Invalid Admintoken used from " + remoteEndpointString); } return guestPermissionLevel; } public static void SetResponseTextContent (HttpListenerResponse _resp, string _text) { byte[] buf = Encoding.UTF8.GetBytes (_text); _resp.ContentLength64 = buf.Length; _resp.ContentType = "text/html"; _resp.ContentEncoding = Encoding.UTF8; _resp.OutputStream.Write (buf, 0, buf.Length); } } }