add option to request private servers (#7)

This commit is contained in:
CommonLoon102 2020-01-27 22:37:28 +00:00 committed by GitHub
parent c1b385c41d
commit 90ed231088
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 393 additions and 150 deletions

View File

@ -1,10 +1,9 @@
using Model; using System;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Text; using System.Text;
namespace Common namespace Model
{ {
public class SpawnedServerInfo public class SpawnedServerInfo
{ {

View File

@ -16,7 +16,7 @@ namespace WebInterface.Controllers
_stateService = stateService; _stateService = stateService;
} }
[Route("nblood/home")] [Route("nblood/home", Name = "Home")]
public IActionResult Index() public IActionResult Index()
{ {
var servers = _stateService.ListServers(HttpContext.Request.Host.Host).Servers; var servers = _stateService.ListServers(HttpContext.Request.Host.Host).Servers;

View File

@ -24,16 +24,18 @@ namespace WebInterface.Controllers
private readonly ILogger<NBloodController> _logger; private readonly ILogger<NBloodController> _logger;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly IStateService _listServersService; private readonly IStateService _stateService;
private readonly IPrivateServerService _privateServerService;
private static readonly Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); public NBloodController(ILogger<NBloodController> logger,
private static readonly IPEndPoint webApiListenerEndPoint = new IPEndPoint(IPAddress.Loopback, 11028); IConfiguration config,
IStateService stateService,
public NBloodController(ILogger<NBloodController> logger, IConfiguration config, IStateService listServersService) IPrivateServerService privateServerService)
{ {
_logger = logger; _logger = logger;
_config = config; _config = config;
_listServersService = listServersService; _stateService = stateService;
_privateServerService = privateServerService;
} }
[HttpGet] [HttpGet]
@ -42,6 +44,9 @@ namespace WebInterface.Controllers
{ {
try try
{ {
if (parameters.ApiKey != _config.GetValue<string>("ApiKey"))
return new StartServerResponse("Invalid ApiKey.");
Stopwatch sw = Stopwatch.StartNew(); Stopwatch sw = Stopwatch.StartNew();
while (_isBusy) while (_isBusy)
{ {
@ -52,26 +57,16 @@ namespace WebInterface.Controllers
_isBusy = true; _isBusy = true;
if (parameters.Players < 3) var serverProcess = _privateServerService.SpawnNewPrivateServer(parameters.Players, parameters.ModName);
parameters.Players = 3;
if (parameters.ApiKey != _config.GetValue<string>("ApiKey"))
return new StartServerResponse("Invalid ApiKey.");
SpawnedServerInfo serverProcess = ProcessSpawner.SpawnServer(parameters.Players, parameters.ModName);
byte[] payload = Encoding.ASCII.GetBytes($"B{serverProcess.Port}\t{serverProcess.Process.Id}\0");
socket.SendTo(payload, webApiListenerEndPoint);
_logger.LogInformation("Server started waiting for {0} players on port {1}.", _logger.LogInformation("Server started waiting for {0} players on port {1}.",
parameters.Players, serverProcess.Port); parameters.Players, serverProcess.Port);
string commandLine = CommandLineUtils.GetClientLaunchCommand(HttpContext.Request.Host.Host,
serverProcess.Port,
serverProcess.Mod.CommandLine);
Thread.Sleep(TimeSpan.FromSeconds(2)); Thread.Sleep(TimeSpan.FromSeconds(2));
return new StartServerResponse(serverProcess.Port) return new StartServerResponse(serverProcess.Port, commandLine);
{
CommandLine = CommandLineUtils.GetClientLaunchCommand(HttpContext.Request.Host.Host,
serverProcess.Port,
serverProcess.Mod.CommandLine)
};
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -93,7 +88,7 @@ namespace WebInterface.Controllers
if (DateTime.UtcNow - _lastRefresh > TimeSpan.FromSeconds(1) if (DateTime.UtcNow - _lastRefresh > TimeSpan.FromSeconds(1)
|| _lastServerList == null) || _lastServerList == null)
{ {
_lastServerList = _listServersService.ListServers(HttpContext.Request.Host.Host); _lastServerList = _stateService.ListServers(HttpContext.Request.Host.Host);
_lastRefresh = DateTime.UtcNow; _lastRefresh = DateTime.UtcNow;
} }

View File

@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Common;
using Microsoft.AspNetCore.Mvc;
using WebInterface.Services;
namespace WebInterface.Controllers
{
public class PrivateController : Controller
{
private readonly IPrivateServerService _privateServerService;
private readonly IRateLimiterService _rateLimiterService;
public PrivateController(IPrivateServerService privateServerService, IRateLimiterService rateLimiterService)
{
_privateServerService = privateServerService;
_rateLimiterService = rateLimiterService;
}
[Route("nblood/private")]
[HttpGet]
public IActionResult Index()
{
var viewModel = new PrivateViewModel
{
ModName = Constants.SupportedMods.Values.First().Name,
Players = 2
};
return View(viewModel);
}
[Route("nblood/private", Name = "RequestPrivateServer")]
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Index(PrivateViewModel request)
{
StartServerResponse viewModel;
try
{
if (!ModelState.IsValid)
throw new Exception("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.");
var spawnedServer = _privateServerService.SpawnNewPrivateServer(request.Players + 1, request.ModName);
string commandLine = CommandLineUtils.GetClientLaunchCommand(HttpContext.Request.Host.Host,
spawnedServer.Port,
spawnedServer.Mod.CommandLine);
viewModel = new StartServerResponse(spawnedServer.Port, commandLine);
}
catch (Exception ex)
{
viewModel = new StartServerResponse(ex.Message);
}
return View("Result", viewModel);
}
}
}

View File

@ -0,0 +1,22 @@
using Common;
using Microsoft.AspNetCore.Mvc.Rendering;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
namespace WebInterface
{
public class PrivateViewModel
{
[Required]
[Display(Name = "Required players")]
public int Players { get; set; }
[Required]
[Display(Name = "Select mod")]
public string ModName { get; set; }
public List<SelectListItem> ModNames { get; } = Constants.SupportedMods
.Select(m => new SelectListItem(m.Value.FriendlyName, m.Value.Name))
.ToList();
}
}

View File

@ -18,10 +18,11 @@ namespace WebInterface
ErrorMessage = errorMessage ?? ""; ErrorMessage = errorMessage ?? "";
} }
public StartServerResponse(int port) public StartServerResponse(int port, string commandLine)
{ {
IsSuccess = true; IsSuccess = true;
Port = port; Port = port;
CommandLine = commandLine;
} }
} }
} }

View File

@ -0,0 +1,13 @@
using Model;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace WebInterface.Services
{
public interface IPrivateServerService
{
SpawnedServerInfo SpawnNewPrivateServer(int players, string modName);
}
}

View File

@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
namespace WebInterface.Services
{
public interface IRateLimiterService
{
bool IsRequestAllowed(IPAddress remoteIpAddress);
}
}

View File

@ -0,0 +1,29 @@
using Common;
using Model;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
namespace WebInterface.Services
{
public class PrivateServerService : IPrivateServerService
{
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)
{
players = Math.Min(8, Math.Max(3, players));
SpawnedServerInfo serverProcess = ProcessSpawner.SpawnServer(players, modName);
byte[] payload = Encoding.ASCII.GetBytes($"B{serverProcess.Port}\t{serverProcess.Process.Id}\0");
socket.SendTo(payload, webApiListenerEndPoint);
return serverProcess;
}
}
}

View File

@ -0,0 +1,39 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
namespace WebInterface.Services
{
public class RateLimiterService : IRateLimiterService
{
private static readonly ConcurrentDictionary<IPAddress, List<DateTime>> requestHistory = new ConcurrentDictionary<IPAddress, List<DateTime>>();
public bool IsRequestAllowed(IPAddress remoteIpAddress)
{
if (requestHistory.TryGetValue(remoteIpAddress, out var list))
{
bool isLimited = list.Count(e => DateTime.UtcNow - e < TimeSpan.FromHours(1)) >= 5;
if (isLimited)
return false;
}
requestHistory.AddOrUpdate(remoteIpAddress,
(ip) =>
{
var list = new List<DateTime>();
list.Add(DateTime.UtcNow);
return list;
},
(ip, list) =>
{
list.Add(DateTime.UtcNow);
return list;
});
return true;
}
}
}

View File

@ -40,6 +40,8 @@ namespace WebInterface
services.AddControllers(); services.AddControllers();
services.AddControllersWithViews(); services.AddControllersWithViews();
services.Add(new ServiceDescriptor(typeof(IStateService), typeof(StateService), ServiceLifetime.Singleton)); services.Add(new ServiceDescriptor(typeof(IStateService), typeof(StateService), ServiceLifetime.Singleton));
services.Add(new ServiceDescriptor(typeof(IPrivateServerService), typeof(PrivateServerService), ServiceLifetime.Transient));
services.Add(new ServiceDescriptor(typeof(IRateLimiterService), typeof(RateLimiterService), ServiceLifetime.Singleton));
} }
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline. // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.

View File

@ -1,7 +1,9 @@
@model WebInterface.HomeViewModel @model HomeViewModel
@{ @{
Layout = null; ViewData["Title"] = "Blood Servers";
Layout = "~/Views/Shared/_Layout.cshtml";
string b = "Blood"; string b = "Blood";
string cp = "Cryptic Passage"; string cp = "Cryptic Passage";
string dw = "Death Wish"; string dw = "Death Wish";
@ -12,7 +14,7 @@
ListServers = @<div> ListServers = @<div>
@foreach (var server in Model.Servers.Where(s => s.Mod == item).OrderBy(s => s.MaximumPlayers)) @foreach (var server in Model.Servers.Where(s => s.Mod == item).OrderBy(s => s.MaximumPlayers))
{ {
<div style="border:2px solid cornflowerblue;padding-left:1em"> <div class="bordered">
<p style="font-weight:bold">Players: @(server.CurrentPlayers - 1)/@(server.MaximumPlayers - 1)</p> <p style="font-weight:bold">Players: @(server.CurrentPlayers - 1)/@(server.MaximumPlayers - 1)</p>
<div> <div>
@if (server.IsStarted) @if (server.IsStarted)
@ -38,128 +40,98 @@
</div>; </div>;
} }
<!DOCTYPE html> <h1>The client EXE</h1>
<p>Use this exe to connect: <a href="https://lerppu.net/wannabethesis/nblood/20200113-2073/">https://lerppu.net/wannabethesis/nblood/20200113-2073/</a></p>
<html> <p><span class="bolder" style="font-size:larger">@Model.ManHoursPlayed</span> man-hours played since @Model.RunningSinceUtc</p>
<head>
<meta name="viewport" content="width=device-width" />
<title>Blood servers</title>
<style>
.code {
font-family: Consolas;
background-color: black;
color: whitesmoke;
}
.warning { <div class="bordered" style="padding-left:1em;padding-bottom:1em">
border: dashed 4px red; <p>If you want to play with friends only and keep your port secret, request a private server by clicking the button below:</p>
font-weight: bold; <form method="get">
} <button asp-route="RequestPrivateServer">Request Private Server</button>
</form>
</div>
.bolder { <h2>@b 1.21</h2>
font-weight: bolder; <details>
} <summary>Show @b <span class="bolder">version 1.21</span> servers</summary>
<p>The below files must be in your Blood directory:</p>
.main { <ul>
margin: auto; <li>BLOOD.INI</li>
max-width: 900px; <li>BLOOD.RFF</li>
padding: 10px; <li>GUI.RFF</li>
} <li>SOUNDS.RFF</li>
</style> <li>SURFACE.DAT</li>
</head> <li>TILES000.ART-TILES017.ART</li>
<body style="background-color: cornsilk"> <li>VOXEL.DAT</li>
<div class="main"> </ul>
<h1>The client EXE</h1> @ListServers(@b)
<p>Use this exe to connect: <a href="https://lerppu.net/wannabethesis/nblood/20200113-2073/">https://lerppu.net/wannabethesis/nblood/20200113-2073/</a></p> </details>
<br />
<p><span class="bolder" style="font-size:larger">@Model.ManHoursPlayed</span> man-hours played since @Model.RunningSinceUtc</p> <h2>@cp</h2>
<details>
<h2>@b 1.21</h2> <summary>Show @cp servers</summary>
<details> <p>The below files must be in your Blood directory:</p>
<summary>Show @b <span class="bolder">version 1.21</span> servers</summary> <ul>
<p>The below files must be in your Blood directory:</p> <li>CP01-CP09.MAP</li>
<li>CPART07.AR_ (Fresh Supply owners need to copy tiles007.ART from <span class="code">\addons\Cryptic Passage</span> and rename it</li>
<li>CPART15.AR_ (Fresh Supply owners need to copy tiles015.ART from <span class="code">\addons\Cryptic Passage</span> and rename it</li>
<li>CPBB01.MAP-CPBB04.MAP</li>
<li>CPSL.MAP</li>
<li>CRYPTIC.INI</li>
</ul>
<p class="warning">Don't forget to send back the ferry every time at the end of the last map, otherwise you cannot go back if the boss kills you!</p>
@ListServers(@cp)
</details>
<br />
<h2>@dw 1.6.10</h2>
<details>
<summary>Show @dw <span class="bolder">version 1.6.10</span> servers</summary>
<p>The below files must be in your Blood directory:</p>
<ul>
<li>dw.ini</li>
<li>DWBB1.MAP-DWBB3.MAP</li>
<li>DWE1M1.MAP-DWE1M12.MAP</li>
<li>DWE2M1.MAP-DWE2M12.MAP</li>
<li>DWE3M1.MAP-DWE3M12.MAP</li>
</ul>
@ListServers(@dw)
</details>
<br />
<h2>@twoira 1.0.1</h2>
<details>
<summary>Show @twoira <span class="bolder">version 1.0.1</span> servers</summary>
<p>@twoira </p>
<p>The below folder (TWOIRA) must be in your Blood directory, and inside that the other additional files:</p>
<ul>
<li>TWOIRA</li>
<li>
<ul> <ul>
<li>BLOOD.INI</li> <li>IRA01.MAP</li>
<li>BLOOD.RFF</li> <li>IRA02_A.MAP</li>
<li>GUI.RFF</li> <li>IRA02_B.MAP</li>
<li>SOUNDS.RFF</li> <li>IRA03.MAP</li>
<li>IRA04.MAP</li>
<li>IRA05.MAP</li>
<li>IRA06.MAP</li>
<li>IRA07.MAP</li>
<li>IRA08.MAP</li>
<li>SURFACE.DAT</li> <li>SURFACE.DAT</li>
<li>TILES000.ART-TILES017.ART</li> <li>TILES18.ART</li>
<li>VOXEL.DAT</li> <li>twoira.ini</li>
</ul> </ul>
@ListServers(@b) </li>
</details> </ul>
<br /> @ListServers(twoira)
<h2>@cp</h2> </details>
<details> <h2>@fo 1.3</h2>
<summary>Show @cp servers</summary> <details>
<p>The below files must be in your Blood directory:</p> <summary>Show @fo <span class="bolder">version 1.3</span> servers</summary>
<ul> <p>The below files must be in your Blood directory:</p>
<li>CP01-CP09.MAP</li> <ul>
<li>CPART07.AR_ (Fresh Supply owners need to copy tiles007.ART from <span class="code">\addons\Cryptic Passage</span> and rename it</li> <li>fo.INI</li>
<li>CPART15.AR_ (Fresh Supply owners need to copy tiles015.ART from <span class="code">\addons\Cryptic Passage</span> and rename it</li> <li>fo1m1.MAP-fo1m8.MAP</li>
<li>CPBB01.MAP-CPBB04.MAP</li> </ul>
<li>CPSL.MAP</li> <p class="warning">Not tested in co-op! But I hope it works! :)</p>
<li>CRYPTIC.INI</li> @ListServers(@fo)
</ul> </details>
<p class="warning">Don't forget to send back the ferry every time at the end of the last map, otherwise you cannot go back if the boss kills you!</p>
@ListServers(@cp)
</details>
<br />
<h2>@dw 1.6.10</h2>
<details>
<summary>Show @dw <span class="bolder">version 1.6.10</span> servers</summary>
<p>The below files must be in your Blood directory:</p>
<ul>
<li>dw.ini</li>
<li>DWBB1.MAP-DWBB3.MAP</li>
<li>DWE1M1.MAP-DWE1M12.MAP</li>
<li>DWE2M1.MAP-DWE2M12.MAP</li>
<li>DWE3M1.MAP-DWE3M12.MAP</li>
</ul>
@ListServers(@dw)
</details>
<br />
<h2>@twoira 1.0.1</h2>
<details>
<summary>Show @twoira <span class="bolder">version 1.0.1</span> servers</summary>
<p>@twoira </p>
<p>The below folder (TWOIRA) must be in your Blood directory, and inside that the other additional files:</p>
<ul>
<li>TWOIRA</li>
<li>
<ul>
<li>IRA01.MAP</li>
<li>IRA02_A.MAP</li>
<li>IRA02_B.MAP</li>
<li>IRA03.MAP</li>
<li>IRA04.MAP</li>
<li>IRA05.MAP</li>
<li>IRA06.MAP</li>
<li>IRA07.MAP</li>
<li>IRA08.MAP</li>
<li>SURFACE.DAT</li>
<li>TILES18.ART</li>
<li>twoira.ini</li>
</ul>
</li>
</ul>
@ListServers(twoira)
</details>
<h2>@fo 1.3</h2>
<details>
<summary>Show @fo <span class="bolder">version 1.3</span> servers</summary>
<p>The below files must be in your Blood directory:</p>
<ul>
<li>fo.INI</li>
<li>fo1m1.MAP-fo1m8.MAP</li>
</ul>
<p class="warning">Not tested in co-op! But I hope it works! :)</p>
@ListServers(@fo)
</details>
<br />
<hr />
<p>Do you think this page is ugly? You are right! If you want to make it look better, PRs are welcomed (but please, don't use any JavaScript, thanks): <a href="https://github.com/CommonLoon102/NBloodServerSupervisor">https://github.com/CommonLoon102/NBloodServerSupervisor</a></p>
</div>
</body>
</html>

View File

@ -0,0 +1,16 @@
@model PrivateViewModel
@{
ViewData["Title"] = "Request Private Server";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>@ViewData["Title"]</h1>
<div class="bordered" style="padding:4px">
<form method="post">
<label asp-for="ModName"></label>
<select asp-for="ModName" asp-items="Model.ModNames"></select> <br />
<label asp-for="Players"></label>
<input type="number" min="2" max="7" asp-for="Players" /> <br />
<button asp-route="RequestPrivateServer">Request</button>
</form>
</div>

View File

@ -0,0 +1,17 @@
@model StartServerResponse
@{
ViewData["Title"] = "Your Private Server";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<div class="bordered">
@if (Model.IsSuccess)
{
<p>Command: <span class="code">@Model.CommandLine</span></p>
<p>You have 10 minutes to join.</p>
}
else
{
<p>Error: @Model.ErrorMessage</p>
}
</div>

View File

@ -0,0 +1,59 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"]</title>
<style>
.code {
font-family: Consolas;
background-color: black;
color: whitesmoke;
}
.warning {
border: dashed 4px red;
font-weight: bold;
}
.bolder {
font-weight: bolder;
}
.main {
margin: auto;
max-width: 900px;
padding: 10px;
}
.bordered {
border: 2px solid cornflowerblue;
padding-left: 1em;
}
</style>
</head>
<body style="background-color: cornsilk">
<div class="main">
<header>
<form method="get">
<button asp-route="Home">Home</button>
</form>
<hr />
<p>I live... Again!</p>
</header>
<div>
<main role="main">
@RenderBody()
</main>
</div>
<footer class="border-top footer text-muted">
<div>
<hr />
<p>Do you think this page is ugly? You are right! If you want to make it look better, PRs are welcomed (but please, don't use any JavaScript, thanks): <a href="https://github.com/CommonLoon102/NBloodServerSupervisor">https://github.com/CommonLoon102/NBloodServerSupervisor</a></p>
</div>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
@using WebInterface
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers