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

Last change on this file since 440 was 440, checked in by alloc, 18 months ago

*Added: Allow specifying a single level 0 API token on the command line

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