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

Last change on this file since 500 was 500, checked in by alloc, 4 months ago

Fixed NRE when using APIs while server is shutting down

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