source: TFP-WebServer/WebServer/src/Web.cs@ 473

Last change on this file since 473 was 469, checked in by alloc, 15 months ago

21.1.16.4 Release
Webserver forces query parameter decoding to use UTF-8
All mods built with portable debugging symbols

File size: 10.9 KB
RevLine 
[391]1using System;
2using System.Collections.Generic;
3using System.IO;
4using System.Net.Sockets;
[469]5using System.Text;
[391]6using SpaceWizards.HttpListener;
7using UnityEngine;
[402]8using Webserver.FileCache;
[404]9using Webserver.Permissions;
[391]10using Webserver.UrlHandlers;
[459]11using Webserver.WebAPI;
[391]12using Cookie = System.Net.Cookie;
13using HttpStatusCode = System.Net.HttpStatusCode;
14using IPEndPoint = System.Net.IPEndPoint;
15
16namespace Webserver {
17 public class Web : IConsoleServer {
[402]18 public static event Action<Web> ServerInitialized;
19
[391]20 private const string indexPageUrl = "/app";
21
22 private readonly List<AbsHandler> handlers = new List<AbsHandler> ();
[434]23 public readonly List<WebMod> WebMods = new List<WebMod> ();
[412]24 public readonly ConnectionHandler ConnectionHandler;
[459]25 public readonly OpenApiHelpers OpenApiHelpers;
[391]26
27 private readonly HttpListener listener = new HttpListener ();
28 private readonly Version httpProtocolVersion = new Version(1, 1);
29
[404]30 private readonly AsyncCallback handleRequestDelegate;
31
[391]32 public Web (string _modInstancePath) {
33 try {
[412]34 bool dashboardEnabled = GamePrefs.GetBool (EnumUtils.Parse<EnumGamePrefs> (nameof (EnumGamePrefs.WebDashboardEnabled)));
35 if (!dashboardEnabled) {
36 Log.Out ($"[Web] Webserver not started, {nameof (EnumGamePrefs.WebDashboardEnabled)} set to false");
37 return;
38 }
39
40 int webPort = GamePrefs.GetInt (EnumUtils.Parse<EnumGamePrefs> (nameof (EnumGamePrefs.WebDashboardPort)));
[391]41 if (webPort < 1 || webPort > 65533) {
[412]42 Log.Out ($"[Web] Webserver not started ({nameof (EnumGamePrefs.WebDashboardPort)} not within 1-65535)");
[391]43 return;
44 }
45
46 if (!HttpListener.IsSupported) {
[398]47 Log.Out ("[Web] Webserver not started (HttpListener.IsSupported returned false)");
[391]48 return;
49 }
50
[412]51 if (string.IsNullOrEmpty (GamePrefs.GetString (EnumUtils.Parse<EnumGamePrefs> (nameof (EnumGamePrefs.WebDashboardUrl))))) {
52 Log.Warning ($"[Web] {nameof (EnumGamePrefs.WebDashboardUrl)} not set. Recommended to set it to the public URL pointing to your dashboard / reverse proxy");
53 }
54
55 ConnectionHandler = new ConnectionHandler ();
[459]56 OpenApiHelpers = new OpenApiHelpers ();
[391]57
[426]58 RegisterDefaultHandlers (_modInstancePath);
[391]59
[402]60 // Allow other code to add their stuff
61 ServerInitialized?.Invoke (this);
[391]62
63 listener.Prefixes.Add ($"http://+:{webPort}/");
64 listener.Start ();
[404]65 handleRequestDelegate = HandleRequest;
66 listener.BeginGetContext (handleRequestDelegate, listener);
[391]67
68 SdtdConsole.Instance.RegisterServer (this);
69
[402]70 Log.Out ($"[Web] Started Webserver on port {webPort}");
[391]71 } catch (Exception e) {
[398]72 Log.Error ("[Web] Error in Web.ctor: ");
[391]73 Log.Exception (e);
74 }
75 }
76
[426]77 private void RegisterDefaultHandlers (string _modInstancePath) {
78 // TODO: Read from config
79 bool useCacheForStatic = StringParsers.ParseBool ("false");
80
81 string webfilesFolder = DetectWebserverFolder (_modInstancePath);
82
83 RegisterPathHandler ("/", new RewriteHandler ("/files/"));
84
85 // React virtual routing
86 RegisterPathHandler ("/app", new RewriteHandler ("/files/index.html", true));
87
88 // Do mods relatively early as they should not be requested a lot, unlike the later registrations, especially for API and map tiles
89 RegisterWebMods (useCacheForStatic);
90
[453]91 RegisterPathHandler ("/session/", new SessionHandler ());
[426]92 RegisterPathHandler ("/userstatus", new UserStatusHandler ());
93 RegisterPathHandler ("/sse/", new SseHandler ());
94 RegisterPathHandler ("/files/", new StaticHandler (
95 webfilesFolder,
96 useCacheForStatic ? new SimpleCache () : new DirectAccess (),
97 false)
98 );
99 RegisterPathHandler ("/itemicons/", new ItemIconHandler (true));
100 RegisterPathHandler ("/api/", new ApiHandler ());
101 }
102
[398]103 private static string DetectWebserverFolder (string _modInstancePath) {
[431]104 const string webrootFolderName = "webroot";
105
106 string webserverFolder = $"{_modInstancePath}/{webrootFolderName}";
[398]107
108 foreach (Mod mod in ModManager.GetLoadedMods ()) {
[431]109 string modServerFolder = $"{mod.Path}/{webrootFolderName}";
[398]110
111 if (Directory.Exists (modServerFolder)) {
112 webserverFolder = modServerFolder;
113 }
114 }
115
116 Log.Out ($"[Web] Serving basic webserver files from {webserverFolder}");
117
118 return webserverFolder;
119 }
120
[391]121 public void RegisterPathHandler (string _urlBasePath, AbsHandler _handler) {
122 foreach (AbsHandler handler in handlers) {
123 if (handler.UrlBasePath != _urlBasePath) {
124 continue;
125 }
126
[398]127 Log.Error ($"[Web] Handler for relative path {_urlBasePath} already registerd.");
[391]128 return;
129 }
130
131 handlers.Add (_handler);
132 _handler.SetBasePathAndParent (this, _urlBasePath);
133 }
134
135 private void RegisterWebMods (bool _useStaticCache) {
136 foreach (Mod mod in ModManager.GetLoadedMods ()) {
137 try {
138 try {
139 WebMod webMod = new WebMod (this, mod, _useStaticCache);
[434]140 WebMods.Add (webMod);
[391]141 } catch (InvalidDataException e) {
[402]142 Log.Error ($"[Web] Could not load webmod from mod {mod.Name}: {e.Message}");
[391]143 }
144 } catch (Exception e) {
[402]145 Log.Error ($"[Web] Failed loading web mods from mod {mod.Name}");
[391]146 Log.Exception (e);
147 }
148 }
149 }
150
151 public void Disconnect () {
152 try {
153 listener.Stop ();
154 listener.Close ();
155 } catch (Exception e) {
[402]156 Log.Out ($"[Web] Error in Web.Disconnect: {e}");
[391]157 }
158 }
159
160 public void Shutdown () {
161 foreach (AbsHandler handler in handlers) {
162 handler.Shutdown ();
163 }
164 }
165
166 public void SendLine (string _line) {
[412]167 ConnectionHandler.SendLine (_line);
[391]168 }
169
170 public void SendLog (string _formattedMessage, string _plainMessage, string _trace, LogType _type, DateTime _timestamp, long _uptime) {
171 // Do nothing, handled by LogBuffer internally
172 }
173
[394]174 private readonly UnityEngine.Profiling.CustomSampler getContextSampler = UnityEngine.Profiling.CustomSampler.Create ("GetCtx");
[391]175 private readonly UnityEngine.Profiling.CustomSampler authSampler = UnityEngine.Profiling.CustomSampler.Create ("Auth");
[394]176 private readonly UnityEngine.Profiling.CustomSampler cookieSampler = UnityEngine.Profiling.CustomSampler.Create ("ConCookie");
[391]177 private readonly UnityEngine.Profiling.CustomSampler handlerSampler = UnityEngine.Profiling.CustomSampler.Create ("Handler");
178
179 private void HandleRequest (IAsyncResult _result) {
180 HttpListener listenerInstance = (HttpListener)_result.AsyncState;
181 if (!listenerInstance.IsListening) {
182 return;
183 }
184
185#if ENABLE_PROFILER
186 UnityEngine.Profiling.Profiler.BeginThreadProfiling ("AllocsMods", "WebRequest");
[394]187 getContextSampler.Begin ();
[391]188 HttpListenerContext ctx = listenerInstance.EndGetContext (_result);
[394]189 getContextSampler.End ();
[391]190 try {
191#else
192 HttpListenerContext ctx = listenerInstance.EndGetContext (_result);
193 listenerInstance.BeginGetContext (HandleRequest, listenerInstance);
194#endif
195 try {
196 HttpListenerRequest request = ctx.Request;
197 HttpListenerResponse response = ctx.Response;
198 response.SendChunked = false;
199
200 response.ProtocolVersion = httpProtocolVersion;
201
202 // No game yet -> fail request
203 if (GameManager.Instance.World == null) {
204 response.StatusCode = (int) HttpStatusCode.ServiceUnavailable;
205 return;
206 }
207
208 if (request.Url == null) {
209 response.StatusCode = (int) HttpStatusCode.BadRequest;
210 return;
211 }
212
213 authSampler.Begin ();
214 int permissionLevel = DoAuthentication (request, out WebConnection conn);
215 authSampler.End ();
216
217 //Log.Out ("Login status: conn!=null: {0}, permissionlevel: {1}", conn != null, permissionLevel);
218
[394]219 cookieSampler.Begin ();
[391]220 if (conn != null) {
221 Cookie cookie = new Cookie ("sid", conn.SessionID, "/") {
222 Expired = false,
223 Expires = DateTime.MinValue,
224 HttpOnly = true,
225 Secure = false
226 };
227 response.AppendCookie (cookie);
228 }
[394]229 cookieSampler.End ();
[391]230
231 string requestPath = request.Url.AbsolutePath;
232
233 if (requestPath.Length < 2) {
234 response.Redirect (indexPageUrl);
235 return;
236 }
237
[469]238 request.ContentEncoding = Encoding.UTF8;
[391]239 RequestContext context = new RequestContext (requestPath, request, response, conn, permissionLevel);
[415]240
241 if (context.Method == ERequestMethod.Other) {
242 context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
243 return;
244 }
[391]245
246 ApplyPathHandler (context);
247
248 } catch (IOException e) {
249 if (e.InnerException is SocketException) {
[402]250 Log.Out ($"[Web] Error in Web.HandleRequest(): Remote host closed connection: {e.InnerException.Message}");
[391]251 } else {
[402]252 Log.Out ($"[Web] Error (IO) in Web.HandleRequest(): {e}");
[391]253 }
254 } catch (Exception e) {
[398]255 Log.Error ("[Web] Error in Web.HandleRequest(): ");
[391]256 Log.Exception (e);
257 } finally {
258 if (!ctx.Response.SendChunked) {
259 ctx.Response.Close ();
260 }
261 }
262#if ENABLE_PROFILER
263 } finally {
[404]264 listenerInstance.BeginGetContext (handleRequestDelegate, listenerInstance);
[391]265 UnityEngine.Profiling.Profiler.EndThreadProfiling ();
266 }
267#endif
268 }
269
270 public void ApplyPathHandler (RequestContext _context) {
271 for (int i = handlers.Count - 1; i >= 0; i--) {
272 AbsHandler handler = handlers [i];
273
274 if (!_context.RequestPath.StartsWith (handler.UrlBasePath)) {
275 continue;
276 }
277
[440]278 if (!handler.IsAuthorizedForHandler (_context)) {
[391]279 _context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
280 if (_context.Connection != null) {
281 //Log.Out ("Web.HandleRequest: user '{0}' not allowed to access '{1}'", _con.SteamID, handler.ModuleName);
282 }
283 } else {
284 handlerSampler.Begin ();
285 handler.HandleRequest (_context);
286 handlerSampler.End ();
287 }
288
289 return;
290 }
291
292 // Not really relevant for non-debugging purposes:
293 //Log.Out ("Error in Web.HandleRequest(): No handler found for path \"" + _requestPath + "\"");
294 _context.Response.StatusCode = (int) HttpStatusCode.NotFound;
295 }
296
297 private int DoAuthentication (HttpListenerRequest _req, out WebConnection _con) {
298 _con = null;
299
300 string sessionId = _req.Cookies ["sid"]?.Value;
301
302 IPEndPoint reqRemoteEndPoint = _req.RemoteEndPoint;
303 if (reqRemoteEndPoint == null) {
[398]304 Log.Warning ("[Web] No RemoteEndPoint on web request");
[426]305 return AdminWebModules.PermissionLevelGuest;
[391]306 }
307
308 if (!string.IsNullOrEmpty (sessionId)) {
[412]309 _con = ConnectionHandler.IsLoggedIn (sessionId, reqRemoteEndPoint.Address);
[391]310 if (_con != null) {
[404]311 int level1 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.UserId);
312 int level2 = int.MaxValue;
313 if (_con.CrossplatformUserId != null) {
314 level2 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.CrossplatformUserId);
315 }
316
317 return Math.Min (level1, level2);
[391]318 }
319 }
320
[404]321 if (!_req.Headers.TryGetValue ("X-SDTD-API-TOKENNAME", out string apiTokenName) ||
322 !_req.Headers.TryGetValue ("X-SDTD-API-SECRET", out string apiTokenSecret)) {
[426]323 return AdminWebModules.PermissionLevelGuest;
[391]324 }
325
[404]326 int adminLevel = AdminApiTokens.Instance.GetPermissionLevel (apiTokenName, apiTokenSecret);
327 if (adminLevel < int.MaxValue) {
328 return adminLevel;
[391]329 }
330
[402]331 Log.Warning ($"[Web] Invalid Admintoken used from {reqRemoteEndPoint}");
[391]332
[426]333 return AdminWebModules.PermissionLevelGuest;
[391]334 }
335 }
336}
Note: See TracBrowser for help on using the repository browser.