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

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

Updated to dashboard/marker files 0.8.0
Added initial OpenAPI support

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