using System; using System.Collections.Generic; using System.IO; using System.Net.Sockets; using System.Text; using SpaceWizards.HttpListener; using UnityEngine; using Webserver.FileCache; using Webserver.Permissions; using Webserver.UrlHandlers; using Webserver.WebAPI; 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 string indexPageUrl = "/app"; private readonly List handlers = new List (); public readonly List WebMods = new List (); public readonly ConnectionHandler ConnectionHandler; public readonly OpenApiHelpers OpenApiHelpers; private readonly HttpListener listener = new HttpListener (); private readonly Version httpProtocolVersion = new Version(1, 1); private readonly AsyncCallback handleRequestDelegate; public Web (string _modInstancePath) { try { bool dashboardEnabled = GamePrefs.GetBool (EnumUtils.Parse (nameof (EnumGamePrefs.WebDashboardEnabled))); if (!dashboardEnabled) { Log.Out ($"[Web] Webserver not started, {nameof (EnumGamePrefs.WebDashboardEnabled)} set to false"); return; } int webPort = GamePrefs.GetInt (EnumUtils.Parse (nameof (EnumGamePrefs.WebDashboardPort))); if (webPort < 1 || webPort > 65533) { Log.Out ($"[Web] Webserver not started ({nameof (EnumGamePrefs.WebDashboardPort)} not within 1-65535)"); return; } if (!HttpListener.IsSupported) { Log.Out ("[Web] Webserver not started (HttpListener.IsSupported returned false)"); return; } if (string.IsNullOrEmpty (GamePrefs.GetString (EnumUtils.Parse (nameof (EnumGamePrefs.WebDashboardUrl))))) { Log.Warning ($"[Web] {nameof (EnumGamePrefs.WebDashboardUrl)} not set. Recommended to set it to the public URL pointing to your dashboard / reverse proxy"); } ConnectionHandler = new ConnectionHandler (); OpenApiHelpers = new OpenApiHelpers (); RegisterDefaultHandlers (_modInstancePath); // Allow other code to add their stuff ServerInitialized?.Invoke (this); listener.Prefixes.Add ($"http://+:{webPort}/"); listener.Start (); handleRequestDelegate = HandleRequest; listener.BeginGetContext (handleRequestDelegate, 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 void RegisterDefaultHandlers (string _modInstancePath) { // TODO: Read from config bool useCacheForStatic = StringParsers.ParseBool ("false"); string webfilesFolder = DetectWebserverFolder (_modInstancePath); RegisterPathHandler ("/", new RewriteHandler ("/files/")); // React virtual routing RegisterPathHandler ("/app", new RewriteHandler ("/files/index.html", true)); // Do mods relatively early as they should not be requested a lot, unlike the later registrations, especially for API and map tiles RegisterWebMods (useCacheForStatic); RegisterPathHandler ("/session/", new SessionHandler ()); RegisterPathHandler ("/userstatus", new UserStatusHandler ()); RegisterPathHandler ("/sse/", new SseHandler ()); RegisterPathHandler ("/files/", new StaticHandler ( webfilesFolder, useCacheForStatic ? new SimpleCache () : new DirectAccess (), false) ); RegisterPathHandler ("/itemicons/", new ItemIconHandler (true)); RegisterPathHandler ("/api/", new ApiHandler ()); } private static string DetectWebserverFolder (string _modInstancePath) { const string webrootFolderName = "webroot"; string webserverFolder = $"{_modInstancePath}/{webrootFolderName}"; foreach (Mod mod in ModManager.GetLoadedMods ()) { string modServerFolder = $"{mod.Path}/{webrootFolderName}"; 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 { 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; } request.ContentEncoding = Encoding.UTF8; RequestContext context = new RequestContext (requestPath, request, response, conn, permissionLevel); if (context.Method == ERequestMethod.Other) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; return; } 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 (handleRequestDelegate, 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)) { _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 AdminWebModules.PermissionLevelGuest; } if (!string.IsNullOrEmpty (sessionId)) { _con = ConnectionHandler.IsLoggedIn (sessionId, reqRemoteEndPoint.Address); if (_con != null) { int level1 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.UserId); int level2 = int.MaxValue; if (_con.CrossplatformUserId != null) { level2 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.CrossplatformUserId); } return Math.Min (level1, level2); } } if (!_req.Headers.TryGetValue ("X-SDTD-API-TOKENNAME", out string apiTokenName) || !_req.Headers.TryGetValue ("X-SDTD-API-SECRET", out string apiTokenSecret)) { return AdminWebModules.PermissionLevelGuest; } int adminLevel = AdminApiTokens.Instance.GetPermissionLevel (apiTokenName, apiTokenSecret); if (adminLevel < int.MaxValue) { return adminLevel; } Log.Warning ($"[Web] Invalid Admintoken used from {reqRemoteEndPoint}"); return AdminWebModules.PermissionLevelGuest; } } }