diff --git a/src/Tgstation.Server.Api/Models/Administration.cs b/src/Tgstation.Server.Api/Models/Administration.cs
index 9e51bd7d3c..99421efdbe 100644
--- a/src/Tgstation.Server.Api/Models/Administration.cs
+++ b/src/Tgstation.Server.Api/Models/Administration.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using Tgstation.Server.Api.Rights;
namespace Tgstation.Server.Api.Models
@@ -7,6 +6,12 @@ namespace Tgstation.Server.Api.Models
///
public sealed class Administration
{
+ ///
+ /// The GitHub repository the server is built to recieve updates from
+ ///
+ [Permissions(DenyWrite = true)]
+ public Uri TrackedRepositoryUrl { get; set; }
+
///
/// The latest available version of the Tgstation.Server.Host assembly from the upstream repository. If is higher than 's the update cannot be applied due to API changes
///
diff --git a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs
index 58f208711c..3aaad4d552 100644
--- a/src/Tgstation.Server.Api/Rights/AdministrationRights.cs
+++ b/src/Tgstation.Server.Api/Rights/AdministrationRights.cs
@@ -19,7 +19,7 @@ namespace Tgstation.Server.Api.Rights
///
/// User can gracefully restart the host
///
- SoftStop = 2,
+ RestartHost = 2,
///
/// User can change
///
diff --git a/src/Tgstation.Server.Host/Configuration/UpdatesConfiguration.cs b/src/Tgstation.Server.Host/Configuration/UpdatesConfiguration.cs
new file mode 100644
index 0000000000..3a5aff123a
--- /dev/null
+++ b/src/Tgstation.Server.Host/Configuration/UpdatesConfiguration.cs
@@ -0,0 +1,28 @@
+namespace Tgstation.Server.Host.Configuration
+{
+ ///
+ /// Configuration for the automatic update system
+ ///
+ sealed class UpdatesConfiguration
+ {
+ ///
+ /// The key for the the resides in
+ ///
+ public const string Section = "Updates";
+
+ ///
+ /// The of the tgstation-server fork to recieve updates from
+ ///
+ public long GitHubRepositoryId { get; set; }
+
+ ///
+ /// Prefix before the of TGS published in git tags
+ ///
+ public string GitTagPrefix { get; set; }
+
+ ///
+ /// Asset package containing the new assembly in zip form
+ ///
+ public string UpdatePackageAssetName { get; set; }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Controllers/AdministrationController.cs b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs
new file mode 100644
index 0000000000..a7819ba632
--- /dev/null
+++ b/src/Tgstation.Server.Host/Controllers/AdministrationController.cs
@@ -0,0 +1,160 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Octokit;
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Threading;
+using System.Threading.Tasks;
+using Tgstation.Server.Api.Models;
+using Tgstation.Server.Api.Rights;
+using Tgstation.Server.Host.Configuration;
+using Tgstation.Server.Host.Core;
+using Tgstation.Server.Host.IO;
+using Tgstation.Server.Host.Models;
+using Tgstation.Server.Host.Security;
+
+namespace Tgstation.Server.Host.Controllers
+{
+ ///
+ /// for
+ ///
+ [Route("/" + nameof(Administration))]
+ sealed class AdministrationController : ModelController
+ {
+ ///
+ /// HTTP 429 status code
+ ///
+ const int RateLimitHttpStatusCode = 429;
+
+ ///
+ /// The for the
+ ///
+ readonly IGitHubClient gitHubClient;
+
+ ///
+ /// The for the
+ ///
+ readonly IServerUpdater serverUpdater;
+
+ ///
+ /// The for the
+ ///
+ readonly IApplication application;
+
+ ///
+ /// The for the
+ ///
+ readonly IIOManager ioManager;
+
+ ///
+ /// The for the
+ ///
+ readonly ILogger logger;
+
+ ///
+ /// The for the
+ ///
+ readonly UpdatesConfiguration updatesConfiguration;
+
+ ///
+ /// Construct an
+ ///
+ /// The for the
+ /// The for the
+ /// The value of
+ /// The value of
+ /// The value of
+ /// The value of
+ /// The value of
+ /// The containing value of
+ public AdministrationController(IDatabaseContext databaseContext, IAuthenticationContextFactory authenticationContextFactory, IGitHubClient gitHubClient, IServerUpdater serverUpdater, IApplication application, IIOManager ioManager, ILogger logger, IOptions updatesConfigurationOptions) : base(databaseContext, authenticationContextFactory)
+ {
+ this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ this.gitHubClient = gitHubClient ?? throw new ArgumentNullException(nameof(gitHubClient));
+ this.serverUpdater = serverUpdater ?? throw new ArgumentNullException(nameof(serverUpdater));
+ this.application = application ?? throw new ArgumentNullException(nameof(application));
+ this.ioManager = ioManager ?? throw new ArgumentNullException(nameof(ioManager));
+ updatesConfiguration = updatesConfigurationOptions?.Value ?? throw new ArgumentNullException(nameof(updatesConfigurationOptions));
+ }
+
+ StatusCodeResult RateLimit(RateLimitExceededException exception)
+ {
+ logger.LogWarning("Exceeded GitHub rate limit!");
+ var secondsString = Math.Ceiling((exception.Reset - DateTimeOffset.Now).TotalSeconds).ToString(CultureInfo.InvariantCulture);
+ Response.Headers.Add("Retry-After", new Microsoft.Extensions.Primitives.StringValues { });
+ return StatusCode(RateLimitHttpStatusCode);
+ }
+
+ ///
+ [TgsAuthorize]
+ public override async Task Read(CancellationToken cancellationToken)
+ {
+ var model = new Administration
+ {
+ CurrentVersion = application.Version
+ };
+ try
+ {
+ var repositoryTask = gitHubClient.Repository.Get(updatesConfiguration.GitHubRepositoryId);
+ var releases = (await gitHubClient.Repository.Release.GetAll(updatesConfiguration.GitHubRepositoryId).ConfigureAwait(false)).Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture));
+
+ Version greatestVersion = null;
+ foreach (var I in releases)
+ if (Version.TryParse(I.TagName.Replace(updatesConfiguration.GitTagPrefix, String.Empty), out var version)
+ && version.Major == application.Version.Major
+ && (greatestVersion == null || version > greatestVersion))
+ greatestVersion = version;
+
+ model.LatestVersion = greatestVersion;
+ model.TrackedRepositoryUrl = new Uri((await repositoryTask.ConfigureAwait(false)).HtmlUrl);
+ }
+ catch (RateLimitExceededException e)
+ {
+ return RateLimit(e);
+ }
+ return Json(model);
+ }
+
+ ///
+ [TgsAuthorize(AdministrationRights.ChangeVersion)]
+ public override async Task Update([FromBody] Administration model, CancellationToken cancellationToken)
+ {
+ if (model == null)
+ throw new ArgumentNullException(nameof(model));
+
+ if (model.CurrentVersion == null)
+ return BadRequest(new { message = "Missing new version!" });
+
+ if (model.CurrentVersion.Major != application.Version.Major)
+ return BadRequest(new { message = "Cannot update to a different suite version!" });
+
+ IEnumerable releases;
+ try
+ {
+ releases = (await gitHubClient.Repository.Release.GetAll(updatesConfiguration.GitHubRepositoryId).ConfigureAwait(false)).Where(x => x.TagName.StartsWith(updatesConfiguration.GitTagPrefix, StringComparison.InvariantCulture));
+ }
+ catch (RateLimitExceededException e)
+ {
+ return RateLimit(e);
+ }
+
+ foreach (var release in releases)
+ if (Version.TryParse(release.TagName.Replace(updatesConfiguration.GitTagPrefix, String.Empty), out var version) && version == model.CurrentVersion)
+ {
+ var asset = release.Assets.Where(x => x.Name == updatesConfiguration.UpdatePackageAssetName).FirstOrDefault();
+ if (asset == default)
+ continue;
+
+ var assetBytes = await ioManager.DownloadFile(new Uri(asset.Url), cancellationToken).ConfigureAwait(false);
+ await serverUpdater.ApplyUpdate(assetBytes, ioManager, cancellationToken).ConfigureAwait(false);
+ return Ok(); //gtfo of here before all the cancellation tokens fire
+ }
+
+ return StatusCode((int)HttpStatusCode.Gone);
+ }
+ }
+}
diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs
index 689ee11fba..48b5affc83 100644
--- a/src/Tgstation.Server.Host/Core/Application.cs
+++ b/src/Tgstation.Server.Host/Core/Application.cs
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
+using Octokit;
using System;
using System.Globalization;
using System.IdentityModel.Tokens.Jwt;
@@ -29,6 +30,11 @@ namespace Tgstation.Server.Host.Core
///
sealed class Application : IApplication
{
+ ///
+ /// Prefix for string version names
+ ///
+ const string VersionPrefix = "/tg/station server";
+
///
public string HostingPath => serverAddresses.Addresses.First();
@@ -64,7 +70,7 @@ namespace Tgstation.Server.Host.Core
this.hostingEnvironment = hostingEnvironment ?? throw new ArgumentNullException(nameof(hostingEnvironment));
Version = Assembly.GetExecutingAssembly().GetName().Version;
- VersionString = String.Format(CultureInfo.InvariantCulture, "/tg/station server v{0}", Version);
+ VersionString = String.Format(CultureInfo.InvariantCulture, "{0} v{1}", VersionPrefix, Version);
}
///
@@ -77,7 +83,8 @@ namespace Tgstation.Server.Host.Core
{
if (services == null)
throw new ArgumentNullException(nameof(services));
- var workingDir = Environment.CurrentDirectory;
+
+ services.Configure(configuration.GetSection(UpdatesConfiguration.Section));
var databaseConfigurationSection = configuration.GetSection(DatabaseConfiguration.Section);
services.Configure(databaseConfigurationSection);
@@ -146,9 +153,10 @@ namespace Tgstation.Server.Host.Core
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton, PasswordHasher>();
+ services.AddSingleton, PasswordHasher>();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton(x => new GitHubClient(new ProductHeaderValue(VersionPrefix, Version.ToString())));
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
diff --git a/src/Tgstation.Server.Host/appsettings.json b/src/Tgstation.Server.Host/appsettings.json
index 8abbd1eb6a..3ea279ea3c 100644
--- a/src/Tgstation.Server.Host/appsettings.json
+++ b/src/Tgstation.Server.Host/appsettings.json
@@ -18,6 +18,11 @@
}
}
},
+ "Updates": {
+ "GitHubRepositoryId": 92952846,
+ "GitTagPrefix": "tgstation-server-v",
+ "UpdatePackageAssetName": "ServerUpdatePackage.zip"
+ },
"Database": {
"DatabaseType": "SqlServer",
"ResetAdminPassword": false,