using System; using System.Collections.Generic; using System.IO; using System.Net.Sockets; using SpaceWizards.HttpListener; using UnityEngine; using Webserver.FileCache; using Webserver.UrlHandlers; using Cookie = System.Net.Cookie; using HttpStatusCode = System.Net.HttpStatusCode; using IPEndPoint = System.Net.IPEndPoint; namespace Webserver { public class Web : IConsoleServer { public static event Action ServerInitialized; private const int guestPermissionLevel = 2000; private const string indexPageUrl = "/app"; 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 (nameof (EnumGamePrefs.ControlPanelPort))); if (webPort < 1 || webPort > 65533) { Log.Out ("[Web] 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 ("[Web] Webserver not started (HttpListener.IsSupported returned false)"); return; } // TODO: Read from config bool useStaticCache = false; string webfilesFolder = DetectWebserverFolder (_modInstancePath); 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 ? 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 (connectionHandler)); RegisterPathHandler ("/userstatus", new UserStatusHandler ()); RegisterPathHandler ("/sse/", new SseHandler ()); RegisterPathHandler ("/files/", new StaticHandler ( webfilesFolder, useStaticCache ? new SimpleCache () : new DirectAccess (), false) ); RegisterPathHandler ("/itemicons/", new ItemIconHandler (true)); RegisterPathHandler ("/api/", new ApiHandler ()); // Allow other code to add their stuff ServerInitialized?.Invoke (this); listener.Prefixes.Add ($"http://+:{webPort}/"); // listener.Prefixes.Add ($"http://[::1]:{webPort}/"); listener.Start (); listener.BeginGetContext (HandleRequest, listener); SdtdConsole.Instance.RegisterServer (this); Log.Out ($"[Web] Started Webserver on port {webPort}"); } catch (Exception e) { Log.Error ("[Web] Error in Web.ctor: "); Log.Exception (e); } } private static string DetectWebserverFolder (string _modInstancePath) { string webserverFolder = $"{_modInstancePath}/webserver"; foreach (Mod mod in ModManager.GetLoadedMods ()) { string modServerFolder = $"{mod.Path}/webserver"; if (Directory.Exists (modServerFolder)) { webserverFolder = modServerFolder; } } Log.Out ($"[Web] Serving basic webserver files from {webserverFolder}"); return webserverFolder; } public void RegisterPathHandler (string _urlBasePath, AbsHandler _handler) { foreach (AbsHandler handler in handlers) { if (handler.UrlBasePath != _urlBasePath) { continue; } 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 ($"[Web] Could not load webmod from mod {mod.Name}: {e.Message}"); } } catch (Exception e) { Log.Error ($"[Web] Failed loading web mods from mod {mod.Name}"); Log.Exception (e); } } } public void Disconnect () { try { listener.Stop (); listener.Close (); } catch (Exception e) { Log.Out ($"[Web] 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 } private readonly UnityEngine.Profiling.CustomSampler getContextSampler = UnityEngine.Profiling.CustomSampler.Create ("GetCtx"); private readonly UnityEngine.Profiling.CustomSampler authSampler = UnityEngine.Profiling.CustomSampler.Create ("Auth"); private readonly UnityEngine.Profiling.CustomSampler cookieSampler = UnityEngine.Profiling.CustomSampler.Create ("ConCookie"); private readonly UnityEngine.Profiling.CustomSampler handlerSampler = UnityEngine.Profiling.CustomSampler.Create ("Handler"); private void HandleRequest (IAsyncResult _result) { HttpListener listenerInstance = (HttpListener)_result.AsyncState; if (!listenerInstance.IsListening) { return; } #if ENABLE_PROFILER UnityEngine.Profiling.Profiler.BeginThreadProfiling ("AllocsMods", "WebRequest"); getContextSampler.Begin (); HttpListenerContext ctx = listenerInstance.EndGetContext (_result); getContextSampler.End (); 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; } authSampler.Begin (); int permissionLevel = DoAuthentication (request, out WebConnection conn); authSampler.End (); //Log.Out ("Login status: conn!=null: {0}, permissionlevel: {1}", conn != null, permissionLevel); cookieSampler.Begin (); if (conn != null) { Cookie cookie = new Cookie ("sid", conn.SessionID, "/") { Expired = false, Expires = DateTime.MinValue, HttpOnly = true, Secure = false }; response.AppendCookie (cookie); } cookieSampler.End (); string requestPath = request.Url.AbsolutePath; if (requestPath.Length < 2) { response.Redirect (indexPageUrl); return; } RequestContext context = new RequestContext (requestPath, request, response, conn, permissionLevel); ApplyPathHandler (context); } catch (IOException e) { if (e.InnerException is SocketException) { Log.Out ($"[Web] Error in Web.HandleRequest(): Remote host closed connection: {e.InnerException.Message}"); } else { Log.Out ($"[Web] Error (IO) in Web.HandleRequest(): {e}"); } } catch (Exception e) { Log.Error ("[Web] Error in Web.HandleRequest(): "); Log.Exception (e); } finally { if (!ctx.Response.SendChunked) { ctx.Response.Close (); } } #if ENABLE_PROFILER } finally { listenerInstance.BeginGetContext (HandleRequest, listenerInstance); UnityEngine.Profiling.Profiler.EndThreadProfiling (); } #endif } public void ApplyPathHandler (RequestContext _context) { for (int i = handlers.Count - 1; i >= 0; i--) { AbsHandler handler = handlers [i]; if (!_context.RequestPath.StartsWith (handler.UrlBasePath)) { continue; } if (!handler.IsAuthorizedForHandler (_context.Connection, _context.PermissionLevel)) { _context.Response.StatusCode = (int)HttpStatusCode.Forbidden; if (_context.Connection != null) { //Log.Out ("Web.HandleRequest: user '{0}' not allowed to access '{1}'", _con.SteamID, handler.ModuleName); } } else { handlerSampler.Begin (); handler.HandleRequest (_context); handlerSampler.End (); } return; } // Not really relevant for non-debugging purposes: //Log.Out ("Error in Web.HandleRequest(): No handler found for path \"" + _requestPath + "\""); _context.Response.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 ("[Web] 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); } } if (_req.QueryString ["adminuser"] == null || _req.QueryString ["admintoken"] == null) { return guestPermissionLevel; } WebPermissions.AdminToken admin = WebPermissions.Instance.GetWebAdmin (_req.QueryString ["adminuser"], _req.QueryString ["admintoken"]); if (admin != null) { return admin.permissionLevel; } Log.Warning ($"[Web] Invalid Admintoken used from {reqRemoteEndPoint}"); return guestPermissionLevel; } } }