add option to play custom maps on private servers (#9)

* add option to play custom maps on private servers

* don't show every exception to the end-user

* hash the IPs

* add description about the purpose of the public custom map list

* add punctuation to error messages
This commit is contained in:
CommonLoon102 2020-01-29 15:32:23 +00:00 committed by GitHub
parent 3417571b70
commit 3af4c9dfc7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 240 additions and 34 deletions

View File

@ -9,6 +9,7 @@ namespace Common
public static class CommandLineUtils
{
public static string BloodDir => Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "blood");
public static string TempMapDir => Path.Combine(BloodDir, "tempmaps");
public static string GetClientLaunchCommand(string host, int port, string modCommandLine) =>
$"nblood -client {host} -port {port} {modCommandLine}";

View File

@ -7,6 +7,8 @@ namespace Common
{
public class Constants
{
public const long FileSizeLimit = 1024 * 1024;
public static readonly IReadOnlyDictionary<string, Mod> SupportedMods = new Dictionary<string, Mod>()
{
{ "BLOOD", new Mod("BLOOD", "Blood", "") },

View File

@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Text;
using System.Linq;
using System.Net.NetworkInformation;
using System.IO;
namespace Common
{
@ -18,7 +19,7 @@ namespace Common
private static readonly Random rnd = new Random();
public static SpawnedServerInfo SpawnServer(int players, string modName)
public static SpawnedServerInfo SpawnServer(int players, string modName, string tempFolderName = "")
{
int serversRunning = Process.GetProcessesByName(nBloodExecutable).Count();
if (serversRunning >= maximumServers)
@ -27,7 +28,7 @@ namespace Common
Mod mod = GetModByName(modName);
int port = GetPort();
var process = Process.Start(GetProcessStartInfo(players, port, mod));
var process = Process.Start(GetProcessStartInfo(players, port, mod, tempFolderName));
return new SpawnedServerInfo(process, port, mod);
}
@ -37,7 +38,7 @@ namespace Common
return Constants.SupportedMods["BLOOD"];
if (!Constants.SupportedMods.ContainsKey(modName.ToUpper()))
throw new Exception("This mod is not supported: " + modName);
throw new Exception($"This mod is not supported: {modName}.");
return Constants.SupportedMods[modName.ToUpper()];
}
@ -59,9 +60,11 @@ namespace Common
return port;
}
private static ProcessStartInfo GetProcessStartInfo(int maxPlayers, int port, Mod mod)
private static ProcessStartInfo GetProcessStartInfo(int maxPlayers, int port, Mod mod, string tempFolderName = "")
{
var psi = new ProcessStartInfo(GetExecutableName(), $"-server {maxPlayers} -port {port} -pname Server {mod.CommandLine}")
string cmd = GetCommand(maxPlayers, port, mod, tempFolderName);
var psi = new ProcessStartInfo(GetExecutableName(), cmd)
{
UseShellExecute = true,
WorkingDirectory = CommandLineUtils.BloodDir
@ -70,6 +73,18 @@ namespace Common
return psi;
}
private static string GetCommand(int maxPlayers, int port, Mod mod, string tempFolderName)
{
string cmd = $"-server {maxPlayers} -port {port} -pname Server {mod.CommandLine}";
if (!string.IsNullOrWhiteSpace(tempFolderName))
{
string tempFolderPath = Path.Combine(CommandLineUtils.TempMapDir, tempFolderName);
cmd += $" -j={tempFolderPath}";
}
return cmd;
}
private static string GetExecutableName()
{
string nbloodServer = nBloodExecutable;

View File

@ -5,6 +5,8 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Diagnostics;
using System.IO;
using Common;
namespace Supervisor
{
@ -16,7 +18,8 @@ namespace Supervisor
{
while (true)
{
Thread.Sleep(TimeSpan.FromSeconds(2));
TempMapFolderCleanup();
Thread.Sleep(TimeSpan.FromSeconds(10));
KillUnusedServers();
}
});
@ -42,5 +45,28 @@ namespace Supervisor
// Log...
}
}
private static void TempMapFolderCleanup()
{
try
{
if (!Directory.Exists(CommandLineUtils.TempMapDir))
{
Directory.CreateDirectory(CommandLineUtils.TempMapDir);
}
foreach (var dir in Directory.GetDirectories(CommandLineUtils.TempMapDir))
{
if (DateTime.UtcNow - File.GetCreationTimeUtc(dir) > TimeSpan.FromDays(1))
{
Directory.Delete(dir, recursive: true);
}
}
}
catch
{
// Log...
}
}
}
}

View File

@ -18,6 +18,8 @@ namespace WebInterface.Controllers
[ApiController]
public class NBloodController : ControllerBase
{
private const string generalErrorMessage = "Unhandled exception has been occured. Check the server logs for details.";
private static bool _isBusy = false;
private static DateTime _lastRefresh = DateTime.MinValue;
private static ListServersResponse _lastServerList = null;
@ -71,7 +73,7 @@ namespace WebInterface.Controllers
catch (Exception ex)
{
_logger.LogError(ex.ToString());
return new StartServerResponse("Unhandled exception has been occured. Check the logs for details.");
return new StartServerResponse(generalErrorMessage);
}
finally
{
@ -97,7 +99,7 @@ namespace WebInterface.Controllers
catch (Exception ex)
{
_logger.LogError(ex.ToString());
return new ListServersResponse("Unhandled exception has been occured. Check the logs for details.");
return new ListServersResponse(generalErrorMessage);
}
}
}

View File

@ -1,9 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Common;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using WebInterface.Infrastructure;
using WebInterface.Services;
namespace WebInterface.Controllers
@ -12,11 +13,18 @@ namespace WebInterface.Controllers
{
private readonly IPrivateServerService _privateServerService;
private readonly IRateLimiterService _rateLimiterService;
private readonly ICustomMapService _customMapService;
private readonly ILogger<PrivateController> _logger;
public PrivateController(IPrivateServerService privateServerService, IRateLimiterService rateLimiterService)
public PrivateController(IPrivateServerService privateServerService,
IRateLimiterService rateLimiterService,
ICustomMapService customMapService,
ILogger<PrivateController> logger)
{
_privateServerService = privateServerService;
_rateLimiterService = rateLimiterService;
_customMapService = customMapService;
_logger = logger;
}
[Route("nblood/private")]
@ -41,22 +49,31 @@ namespace WebInterface.Controllers
try
{
if (!ModelState.IsValid)
throw new Exception("Something went off the rails.");
throw new WebInterfaceException("Something went off the rails.");
if (!_rateLimiterService.IsRequestAllowed(HttpContext.Connection.RemoteIpAddress))
throw new Exception("Sorry, you have requested too many servers recently, you need to wait some time.");
throw new WebInterfaceException("Sorry, you have requested too many servers recently, you need to wait some time.");
var spawnedServer = _privateServerService.SpawnNewPrivateServer(request.Players + 1, request.ModName);
string tempFolderName = "";
if ((request.FormFile?.Length ?? 0) > 0)
tempFolderName = _customMapService.StoreTempCustomMap(request.FormFile);
var spawnedServer = _privateServerService.SpawnNewPrivateServer(request.Players + 1, request.ModName, tempFolderName ?? "");
string commandLine = CommandLineUtils.GetClientLaunchCommand(HttpContext.Request.Host.Host,
spawnedServer.Port,
spawnedServer.Mod.CommandLine);
viewModel = new StartServerResponse(spawnedServer.Port, commandLine);
}
catch (Exception ex)
catch (WebInterfaceException ex)
{
viewModel = new StartServerResponse(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex.ToString());
viewModel = new StartServerResponse("Internal server error.");
}
return View("Result", viewModel);
}

View File

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace WebInterface.Infrastructure
{
[Serializable]
public class WebInterfaceException : Exception
{
public WebInterfaceException() { }
public WebInterfaceException(string message) : base(message) { }
public WebInterfaceException(string message, Exception inner) : base(message, inner) { }
protected WebInterfaceException(
System.Runtime.Serialization.SerializationInfo info,
System.Runtime.Serialization.StreamingContext context) : base(info, context) { }
}
}

View File

@ -1,4 +1,6 @@
using Common;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using System;
using System.Collections.Generic;
@ -7,6 +9,8 @@ using System.Linq;
namespace WebInterface
{
[RequestFormLimits(MultipartBodyLengthLimit = Constants.FileSizeLimit)]
[RequestSizeLimit(Constants.FileSizeLimit)]
public class PrivateViewModel
{
[Required]
@ -18,5 +22,8 @@ namespace WebInterface
public List<SelectListItem> ModNames { get; } = Constants.SupportedMods
.Select(m => new SelectListItem(m.Value.FriendlyName, m.Value.Name))
.ToList();
[Display(Name = "Custom map (optional)")]
public IFormFile FormFile { get; set; }
}
}

View File

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Common;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
@ -20,7 +21,8 @@ namespace WebInterface
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.UseStartup<Startup>()
.ConfigureKestrel((context, options) => options.Limits.MaxRequestBodySize = Constants.FileSizeLimit);
});
}
}

View File

@ -1,8 +1,11 @@
using System;
using Common;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using WebInterface.Infrastructure;
namespace WebInterface.Services
{
@ -26,7 +29,7 @@ namespace WebInterface.Services
"CPBB04.MAP",
};
private List<string> ListableCustomMaps => Directory.GetFiles(Common.CommandLineUtils.BloodDir,
private List<string> ListableCustomMaps => Directory.GetFiles(CommandLineUtils.BloodDir,
"*.map", SearchOption.TopDirectoryOnly)
.Select(m => Path.GetFileName(m))
.Where(m => !ContainsString(crypticMaps, m))
@ -38,11 +41,11 @@ namespace WebInterface.Services
{
if (ListableCustomMaps.Any(m => StringsAreSame(m, map)))
{
return File.ReadAllBytes(Path.Combine(Common.CommandLineUtils.BloodDir, map));
return File.ReadAllBytes(Path.Combine(CommandLineUtils.BloodDir, map));
}
else
{
throw new Exception($"Cannot download this map: {map}");
throw new WebInterfaceException($"Cannot download this map: {map}.");
}
}
@ -51,5 +54,37 @@ namespace WebInterface.Services
private bool StringsAreSame(string left, string right) =>
string.Compare(left, right, StringComparison.OrdinalIgnoreCase) == 0;
public string StoreTempCustomMap(IFormFile formFile)
{
string filename = Path.GetFileNameWithoutExtension(formFile.FileName);
ValidateFilename(filename);
string tempFolderName = Path.GetRandomFileName();
string mapPath = Path.Combine(CommandLineUtils.TempMapDir, tempFolderName);
if (!Directory.Exists(mapPath))
Directory.CreateDirectory(mapPath);
mapPath = Path.Combine(mapPath, filename + ".map");
FileStream fs = new FileStream(mapPath, FileMode.CreateNew);
formFile.CopyTo(fs);
return tempFolderName;
}
private void ValidateFilename(string filename)
{
if (string.IsNullOrWhiteSpace(filename))
throw new WebInterfaceException("Invalid filename.");
if (ContainsString(crypticMaps, filename + ".map"))
throw new WebInterfaceException($"You cannot play this map ({filename}) as a custom map.");
foreach (var chr in Path.GetInvalidFileNameChars())
{
if (filename.Contains(chr))
throw new WebInterfaceException("Invalid characters in the file name of the custom map.");
}
}
}
}

View File

@ -1,4 +1,5 @@
using System;
using Microsoft.AspNetCore.Http;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -9,5 +10,6 @@ namespace WebInterface.Services
{
IList<string> ListCustomMaps();
byte[] GetCustomMapBytes(string map);
string StoreTempCustomMap(IFormFile formFile);
}
}

View File

@ -8,6 +8,6 @@ namespace WebInterface.Services
{
public interface IPrivateServerService
{
SpawnedServerInfo SpawnNewPrivateServer(int players, string modName);
SpawnedServerInfo SpawnNewPrivateServer(int players, string modName, string tempFolderName = "");
}
}

View File

@ -15,11 +15,11 @@ namespace WebInterface.Services
private static readonly Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
private static readonly IPEndPoint webApiListenerEndPoint = new IPEndPoint(IPAddress.Loopback, 11028);
public SpawnedServerInfo SpawnNewPrivateServer(int players, string modName)
public SpawnedServerInfo SpawnNewPrivateServer(int players, string modName, string tempFolderName = "")
{
players = Math.Min(8, Math.Max(3, players));
SpawnedServerInfo serverProcess = ProcessSpawner.SpawnServer(players, modName);
SpawnedServerInfo serverProcess = ProcessSpawner.SpawnServer(players, modName, tempFolderName);
byte[] payload = Encoding.ASCII.GetBytes($"B{serverProcess.Port}\t{serverProcess.Process.Id}\0");
socket.SendTo(payload, webApiListenerEndPoint);

View File

@ -3,24 +3,27 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
namespace WebInterface.Services
{
public class RateLimiterService : IRateLimiterService
{
private static readonly ConcurrentDictionary<IPAddress, List<DateTime>> requestHistory = new ConcurrentDictionary<IPAddress, List<DateTime>>();
private static readonly ConcurrentDictionary<string, List<DateTime>> requestHistory = new ConcurrentDictionary<string, List<DateTime>>();
public bool IsRequestAllowed(IPAddress remoteIpAddress)
{
if (requestHistory.TryGetValue(remoteIpAddress, out var list))
string hash = GetHash(remoteIpAddress);
if (requestHistory.TryGetValue(hash, out var list))
{
bool isLimited = list.Count(e => DateTime.UtcNow - e < TimeSpan.FromHours(1)) >= 5;
if (isLimited)
return false;
}
requestHistory.AddOrUpdate(remoteIpAddress,
requestHistory.AddOrUpdate(hash,
(ip) =>
{
var list = new List<DateTime>();
@ -35,5 +38,25 @@ namespace WebInterface.Services
return true;
}
private string GetHash(IPAddress remoteIpAddress)
{
const string pepper = "Somewhere, over the rainbow, way up this pepper.";
var data = Encoding.ASCII.GetBytes(remoteIpAddress.ToString() + pepper);
string hash;
using (SHA512 shaM = new SHA512Managed())
{
byte[] bytes = shaM.ComputeHash(data);
for (int i = 0; i < 1000; i++)
{
bytes = shaM.ComputeHash(bytes);
}
bytes = bytes.Take(bytes.Length / 4).ToArray();
hash = Encoding.ASCII.GetString(bytes);
}
return hash;
}
}
}

View File

@ -3,8 +3,11 @@ using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using Common;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
@ -28,7 +31,7 @@ namespace WebInterface
supervisor += ".exe";
string supervisorPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), supervisor);
if (!File.Exists(supervisorPath))
throw new Exception($"Couldn't find {supervisor} at {supervisorPath}");
throw new Exception($"Couldn't find {supervisor} at {supervisorPath}.");
Process.Start(supervisorPath);
}
@ -43,6 +46,29 @@ namespace WebInterface
services.Add(new ServiceDescriptor(typeof(IPrivateServerService), typeof(PrivateServerService), ServiceLifetime.Transient));
services.Add(new ServiceDescriptor(typeof(IRateLimiterService), typeof(RateLimiterService), ServiceLifetime.Singleton));
services.Add(new ServiceDescriptor(typeof(ICustomMapService), typeof(CustomMapService), ServiceLifetime.Transient));
services.Configure<FormOptions>(options =>
{
// Set the limit to 1 MB
options.MultipartBodyLengthLimit = Constants.FileSizeLimit;
});
services.AddRazorPages()
.AddRazorPagesOptions(options =>
{
options.Conventions
.AddPageApplicationModelConvention("/nblood/private", model =>
{
model.Filters.Add(
new RequestFormLimitsAttribute()
{
// Set the limit to 1 MB
MultipartBodyLengthLimit = Constants.FileSizeLimit
});
model.Filters.Add(
new RequestSizeLimitAttribute(Constants.FileSizeLimit));
});
});
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -6,6 +6,12 @@
<h1>List of available custom maps</h1>
<p>You can play these custom maps even on the public servers.
If you want to play on your own map which is not listed here,
you can request a new private server and upload your map which will be
only available for that match, and the map won't appear in the below list.
</p>
<ul>
@foreach (var map in Model)
{

View File

@ -6,11 +6,33 @@
<h1>@ViewData["Title"]</h1>
<div class="bordered" style="padding:4px">
<form method="post">
<form enctype="multipart/form-data" method="post">
<dl>
<dt>
<label asp-for="ModName"></label>
<select asp-for="ModName" asp-items="Model.ModNames"></select> <br />
</dt>
<dd>
<select asp-for="ModName" asp-items="Model.ModNames"></select>
</dd>
</dl>
<dl>
<dt>
<label asp-for="Players"></label>
<input type="number" min="2" max="7" asp-for="Players" /> <br />
</dt>
<dd>
<input type="number" min="2" max="7" asp-for="Players" />
</dd>
</dl>
<dl>
<dt>
<label asp-for="FormFile"></label>
</dt>
<dd>
<input asp-for="FormFile" type="file" accept=".map">
</dd>
</dl>
<button asp-route="RequestPrivateServer">Request</button>
</form>
</div>

View File

@ -8,7 +8,8 @@
@if (Model.IsSuccess)
{
<p>Command: <span class="code">@Model.CommandLine</span></p>
<p>You have 10 minutes to join.</p>
<p>At least 1 person must join in 10 minutes, otherwise the server will be killed.</p>
<p>If you have provided a custom map, you have to type its name in the user map section when starting a new game.</p>
}
else
{