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

Last change on this file since 475 was 474, checked in by alloc, 15 months ago

Added locks on AdminTools instead of local to the individual permission systems

File size: 11.0 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 public Web (string _modInstancePath) {
33 try {
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)));
41 if (webPort < 1 || webPort > 65533) {
42 Log.Out ($"[Web] Webserver not started ({nameof (EnumGamePrefs.WebDashboardPort)} not within 1-65535)");
43 return;
44 }
45
46 if (!HttpListener.IsSupported) {
47 Log.Out ("[Web] Webserver not started (HttpListener.IsSupported returned false)");
48 return;
49 }
50
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 ();
56 OpenApiHelpers = new OpenApiHelpers ();
57
58 RegisterDefaultHandlers (_modInstancePath);
59
60 // Allow other code to add their stuff
61 ServerInitialized?.Invoke (this);
62
63 listener.Prefixes.Add ($"http://+:{webPort}/");
64 listener.Start ();
65 handleRequestDelegate = HandleRequest;
66 listener.BeginGetContext (handleRequestDelegate, listener);
67
68 SdtdConsole.Instance.RegisterServer (this);
69
70 Log.Out ($"[Web] Started Webserver on port {webPort}");
71 } catch (Exception e) {
72 Log.Error ("[Web] Error in Web.ctor: ");
73 Log.Exception (e);
74 }
75 }
76
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
91 RegisterPathHandler ("/session/", new SessionHandler ());
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
103 private static string DetectWebserverFolder (string _modInstancePath) {
104 const string webrootFolderName = "webroot";
105
106 string webserverFolder = $"{_modInstancePath}/{webrootFolderName}";
107
108 foreach (Mod mod in ModManager.GetLoadedMods ()) {
109 string modServerFolder = $"{mod.Path}/{webrootFolderName}";
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
121 public void RegisterPathHandler (string _urlBasePath, AbsHandler _handler) {
122 foreach (AbsHandler handler in handlers) {
123 if (handler.UrlBasePath != _urlBasePath) {
124 continue;
125 }
126
127 Log.Error ($"[Web] Handler for relative path {_urlBasePath} already registerd.");
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);
140 WebMods.Add (webMod);
141 } catch (InvalidDataException e) {
142 Log.Error ($"[Web] Could not load webmod from mod {mod.Name}: {e.Message}");
143 }
144 } catch (Exception e) {
145 Log.Error ($"[Web] Failed loading web mods from mod {mod.Name}");
146 Log.Exception (e);
147 }
148 }
149 }
150
151 public void Disconnect () {
152 try {
153 listener.Stop ();
154 listener.Close ();
155 } catch (Exception e) {
156 Log.Out ($"[Web] Error in Web.Disconnect: {e}");
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) {
167 ConnectionHandler.SendLine (_line);
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
174 private readonly UnityEngine.Profiling.CustomSampler getContextSampler = UnityEngine.Profiling.CustomSampler.Create ("GetCtx");
175 private readonly UnityEngine.Profiling.CustomSampler authSampler = UnityEngine.Profiling.CustomSampler.Create ("Auth");
176 private readonly UnityEngine.Profiling.CustomSampler cookieSampler = UnityEngine.Profiling.CustomSampler.Create ("ConCookie");
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");
187 getContextSampler.Begin ();
188 HttpListenerContext ctx = listenerInstance.EndGetContext (_result);
189 getContextSampler.End ();
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
219 cookieSampler.Begin ();
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 }
229 cookieSampler.End ();
230
231 string requestPath = request.Url.AbsolutePath;
232
233 if (requestPath.Length < 2) {
234 response.Redirect (indexPageUrl);
235 return;
236 }
237
238 // Force parsing the URL path as UTF8 so special characters get decoded properly
239 request.ContentEncoding = Encoding.UTF8;
240 RequestContext context = new RequestContext (requestPath, request, response, conn, permissionLevel);
241
242 if (context.Method == ERequestMethod.Other) {
243 context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
244 return;
245 }
246
247 ApplyPathHandler (context);
248
249 } catch (IOException e) {
250 if (e.InnerException is SocketException) {
251 Log.Out ($"[Web] Error in Web.HandleRequest(): Remote host closed connection: {e.InnerException.Message}");
252 } else {
253 Log.Out ($"[Web] Error (IO) in Web.HandleRequest(): {e}");
254 }
255 } catch (Exception e) {
256 Log.Error ("[Web] Error in Web.HandleRequest(): ");
257 Log.Exception (e);
258 } finally {
259 if (!ctx.Response.SendChunked) {
260 ctx.Response.Close ();
261 }
262 }
263#if ENABLE_PROFILER
264 } finally {
265 listenerInstance.BeginGetContext (handleRequestDelegate, listenerInstance);
266 UnityEngine.Profiling.Profiler.EndThreadProfiling ();
267 }
268#endif
269 }
270
271 public void ApplyPathHandler (RequestContext _context) {
272 for (int i = handlers.Count - 1; i >= 0; i--) {
273 AbsHandler handler = handlers [i];
274
275 if (!_context.RequestPath.StartsWith (handler.UrlBasePath)) {
276 continue;
277 }
278
279 if (!handler.IsAuthorizedForHandler (_context)) {
280 _context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
281 if (_context.Connection != null) {
282 //Log.Out ("Web.HandleRequest: user '{0}' not allowed to access '{1}'", _con.SteamID, handler.ModuleName);
283 }
284 } else {
285 handlerSampler.Begin ();
286 handler.HandleRequest (_context);
287 handlerSampler.End ();
288 }
289
290 return;
291 }
292
293 // Not really relevant for non-debugging purposes:
294 //Log.Out ("Error in Web.HandleRequest(): No handler found for path \"" + _requestPath + "\"");
295 _context.Response.StatusCode = (int) HttpStatusCode.NotFound;
296 }
297
298 private int DoAuthentication (HttpListenerRequest _req, out WebConnection _con) {
299 _con = null;
300
301 string sessionId = _req.Cookies ["sid"]?.Value;
302
303 IPEndPoint reqRemoteEndPoint = _req.RemoteEndPoint;
304 if (reqRemoteEndPoint == null) {
305 Log.Warning ("[Web] No RemoteEndPoint on web request");
306 return AdminWebModules.PermissionLevelGuest;
307 }
308
309 if (!string.IsNullOrEmpty (sessionId)) {
310 _con = ConnectionHandler.IsLoggedIn (sessionId, reqRemoteEndPoint.Address);
311 if (_con != null) {
312 int level1 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.UserId);
313 int level2 = int.MaxValue;
314 if (_con.CrossplatformUserId != null) {
315 level2 = GameManager.Instance.adminTools.Users.GetUserPermissionLevel (_con.CrossplatformUserId);
316 }
317
318 return Math.Min (level1, level2);
319 }
320 }
321
322 if (!_req.Headers.TryGetValue ("X-SDTD-API-TOKENNAME", out string apiTokenName) ||
323 !_req.Headers.TryGetValue ("X-SDTD-API-SECRET", out string apiTokenSecret)) {
324 return AdminWebModules.PermissionLevelGuest;
325 }
326
327 int adminLevel = AdminApiTokens.Instance.GetPermissionLevel (apiTokenName, apiTokenSecret);
328 if (adminLevel < int.MaxValue) {
329 return adminLevel;
330 }
331
332 Log.Warning ($"[Web] Invalid Admintoken used from {reqRemoteEndPoint}");
333
334 return AdminWebModules.PermissionLevelGuest;
335 }
336 }
337}
Note: See TracBrowser for help on using the repository browser.