using System; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Threading.Tasks; using JetBrains.Annotations; using Robust.Shared; using Robust.Shared.Asynchronous; using Robust.Shared.Configuration; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Network; using Robust.Shared.Timing; using Robust.Shared.Utility; #nullable enable namespace Robust.Server.ServerStatus { public sealed class WatchdogApi : IWatchdogApi, IPostInjectInit { [Dependency] private readonly IStatusHost _statusHost = default!; [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly ITaskManager _taskManager = default!; [Dependency] private readonly IBaseServer _baseServer = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; // Ping watchdog every 15 seconds. private static readonly TimeSpan PingGap = TimeSpan.FromSeconds(15); private readonly HttpClient _httpClient = new(HappyEyeballsHttp.CreateHttpHandler()); private TimeSpan? _lastPing; private string? _watchdogToken; private string? _watchdogKey; private Uri? _baseUri; private ISawmill _sawmill = default!; public WatchdogApi() { HttpClientUserAgent.AddUserAgent(_httpClient); } public void PostInject() { _sawmill = Logger.GetSawmill("watchdogApi"); _statusHost.AddHandler(UpdateHandler); _statusHost.AddHandler(ShutdownHandler); } private async Task UpdateHandler(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url!.AbsolutePath != "/update") { return false; } if (_watchdogToken == null) { _sawmill.Warning("Watchdog token is unset but received POST /update API call. Ignoring"); return false; } var auth = context.RequestHeaders["WatchdogToken"]; if (auth != _watchdogToken) { // Holy shit nobody read these logs please. _sawmill.Verbose(@"Failed auth: ""{0}"" vs ""{1}""", auth, _watchdogToken); await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } _taskManager.RunOnMainThread(() => UpdateReceived?.Invoke()); await context.RespondAsync("Success", HttpStatusCode.OK); return true; } /// /// This function is used by https://github.com/tgstation/tgstation-server /// Notify the project maintainer(s) if this API is changed. /// private async Task ShutdownHandler(IStatusHandlerContext context) { if (context.RequestMethod != HttpMethod.Post || context.Url!.AbsolutePath != "/shutdown") { return false; } if (_watchdogToken == null) { _sawmill.Warning("Watchdog token is unset but received POST /shutdown API call. Ignoring"); return false; } if (!context.RequestHeaders.TryGetValue("WatchdogToken", out var auth)) { await context.RespondAsync("Expected WatchdogToken header", HttpStatusCode.BadRequest); return true; } if (auth != _watchdogToken) { _sawmill.Verbose( "received POST /shutdown with invalid authentication token. Ignoring {0}, {1}", auth, _watchdogToken); await context.RespondErrorAsync(HttpStatusCode.Unauthorized); return true; } ShutdownParameters? parameters = null; try { parameters = await context.RequestBodyJsonAsync(); } catch (JsonException) { // parameters null so it'll catch the block down below. } if (parameters == null) { await context.RespondErrorAsync(HttpStatusCode.BadRequest); return true; } _taskManager.RunOnMainThread(() => _baseServer.Shutdown(parameters.Reason)); await context.RespondAsync("Success", HttpStatusCode.OK); return true; } public event Action? UpdateReceived; public async void Heartbeat() { if (_watchdogToken == null || _watchdogKey == null || _baseUri == null) { return; } // Ping upon startup to indicate successful init. var realTime = _gameTiming.RealTime; if (_lastPing.HasValue && realTime - _lastPing < PingGap) { return; } _lastPing = realTime; try { // Passing null as content works so... _sawmill.Debug("Sending ping to watchdog..."); using var resp = await _httpClient.PostAsync(new Uri(_baseUri, $"server_api/{_watchdogKey}/ping"), null!); resp.EnsureSuccessStatusCode(); _sawmill.Debug("Succeeded in sending ping to watchdog"); } catch (HttpRequestException e) { _sawmill.Error("Failed to send ping to watchdog:\n{0}", e); } } public void Initialize() { _configurationManager.OnValueChanged(CVars.WatchdogToken, _ => UpdateToken()); _configurationManager.OnValueChanged(CVars.WatchdogKey, _ => UpdateToken()); UpdateToken(); } private void UpdateToken() { var tok = _configurationManager.GetCVar(CVars.WatchdogToken); var key = _configurationManager.GetCVar(CVars.WatchdogKey); var baseUrl = _configurationManager.GetCVar(CVars.WatchdogBaseUrl); _watchdogToken = string.IsNullOrEmpty(tok) ? null : tok; _watchdogKey = string.IsNullOrEmpty(key) ? null : key; _baseUri = string.IsNullOrEmpty(baseUrl) ? null : new Uri(baseUrl); if (_watchdogKey != null && _watchdogToken != null) { var paramStr = $"{_watchdogKey}:{_watchdogToken}"; var param = Convert.ToBase64String(Encoding.UTF8.GetBytes(paramStr)); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", param); } else { _httpClient.DefaultRequestHeaders.Authorization = null; } } [UsedImplicitly] private sealed class ShutdownParameters { // ReSharper disable once RedundantDefaultMemberInitializer public string Reason { get; set; } = default!; } } }