diff --git a/README.md b/README.md index 36788894..c56c8e18 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,20 @@ To deploy this as part of a full osu! server stack deployment, [this wiki page]( For advanced testing purposes. -| Envvar name | Description | Default value | -| :- | :- | :- | -| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) | -| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` | -| `REPLAYS_PATH` | Local path to store complete replay files (`.osr`) to. Only used if [`FileScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/FileScoreStorage.cs) is active. | `./replays/` | -| `S3_KEY` | An access key ID to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | -| `S3_SECRET` | The secret access key to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | -| `REPLAYS_BUCKET` | The name of the [AWS S3](https://aws.amazon.com/s3/) bucket to upload replays to. Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | -| `TRACK_BUILD_USER_COUNTS` | Whether to track how many users are on a particular build of the game and report that information to the database at `DB_{HOST,PORT}`. `1` to enable, any other value to disable. | `""` (disabled) | -| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` | -| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` | -| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` | -| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` | -| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` | -| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` | +| Envvar name | Description | Default value | +| :- | :- |:------------------| +| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) | +| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` | +| `REPLAYS_PATH` | Local path to store complete replay files (`.osr`) to. Only used if [`FileScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/FileScoreStorage.cs) is active. | `./replays/` | +| `S3_KEY` | An access key ID to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | +| `S3_SECRET` | The secret access key to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | +| `REPLAYS_BUCKET` | The name of the [AWS S3](https://aws.amazon.com/s3/) bucket to upload replays to. Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | +| `TRACK_BUILD_USER_COUNTS` | Whether to track how many users are on a particular build of the game and report that information to the database at `DB_{HOST,PORT}`. `1` to enable, any other value to disable. | `""` (disabled) | +| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` | +| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` | +| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` | +| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` | +| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` | +| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` | +| `LEGACY_IO_DOMAIN` | The root URL of the osu-web instance to which legacy IO call should be submitted | `null` (required) | +| `SHARED_INTEROP_SECRET` | The value of the same environment variable that the target osu-web instance specifies in `.env`. | `null` (required) | \ No newline at end of file diff --git a/SampleMultiplayerClient/MultiplayerClient.cs b/SampleMultiplayerClient/MultiplayerClient.cs index 026cd8e0..c3c86d58 100644 --- a/SampleMultiplayerClient/MultiplayerClient.cs +++ b/SampleMultiplayerClient/MultiplayerClient.cs @@ -61,15 +61,14 @@ public MultiplayerClient(HubConnection connection, int userId) public MultiplayerRoom? Room { get; private set; } + public Task CreateRoom(MultiplayerRoom room) + => connection.InvokeAsync(nameof(IMultiplayerServer.CreateRoom), room); + public async Task JoinRoom(long roomId) - { - return await JoinRoomWithPassword(roomId, string.Empty); - } + => await JoinRoomWithPassword(roomId, string.Empty); public async Task JoinRoomWithPassword(long roomId, string? password = null) - { - return Room = await connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty); - } + => Room = await connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty); public async Task LeaveRoom() { diff --git a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs index 0e774719..1ccea078 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs @@ -16,6 +16,7 @@ using osu.Server.Spectator.Database.Models; using osu.Server.Spectator.Entities; using osu.Server.Spectator.Hubs.Multiplayer; +using osu.Server.Spectator.Services; namespace osu.Server.Spectator.Tests.Multiplayer { @@ -37,6 +38,8 @@ public abstract class MultiplayerTest protected readonly Mock DatabaseFactory; protected readonly Mock Database; + protected readonly Mock LegacyIO; + /// /// A general non-gameplay receiver for the room with ID . /// @@ -130,13 +133,16 @@ protected MultiplayerTest() loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny())) .Returns(new Mock().Object); + LegacyIO = new Mock(); + Hub = new TestMultiplayerHub( loggerFactoryMock.Object, Rooms, UserStates, DatabaseFactory.Object, new ChatFilters(DatabaseFactory.Object), - hubContext.Object); + hubContext.Object, + LegacyIO.Object); Hub.Groups = Groups.Object; Hub.Clients = Clients.Object; diff --git a/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs new file mode 100644 index 00000000..b29fafa5 --- /dev/null +++ b/osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using osu.Game.Online.Multiplayer; +using Xunit; + +namespace osu.Server.Spectator.Tests.Multiplayer +{ + public class RoomInteropTest : MultiplayerTest + { + [Fact] + public async Task CreateRoom() + { + LegacyIO.Setup(io => io.CreateRoomAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => ROOM_ID); + + await Hub.CreateRoom(new MultiplayerRoom(0)); + LegacyIO.Verify(io => io.CreateRoomAsync(USER_ID, It.IsAny()), Times.Once); + LegacyIO.Verify(io => io.JoinRoomAsync(ROOM_ID, USER_ID), Times.Once); + + using (var usage = await Hub.GetRoom(ROOM_ID)) + { + Assert.NotNull(usage.Item); + Assert.Equal(USER_ID, usage.Item.Users.Single().UserID); + } + } + + [Fact] + public async Task LeaveRoom() + { + await Hub.JoinRoom(ROOM_ID); + LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Never); + + await Hub.LeaveRoom(); + LegacyIO.Verify(io => io.PartRoomAsync(ROOM_ID, USER_ID), Times.Once); + + await Assert.ThrowsAsync(() => Hub.GetRoom(ROOM_ID)); + } + } +} diff --git a/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs b/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs index 54024987..83d46934 100644 --- a/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs +++ b/osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs @@ -6,6 +6,7 @@ using osu.Server.Spectator.Database; using osu.Server.Spectator.Entities; using osu.Server.Spectator.Hubs.Multiplayer; +using osu.Server.Spectator.Services; namespace osu.Server.Spectator.Tests.Multiplayer { @@ -19,8 +20,9 @@ public TestMultiplayerHub( EntityStore users, IDatabaseFactory databaseFactory, ChatFilters chatFilters, - IHubContext hubContext) - : base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext) + IHubContext hubContext, + ILegacyIO legacyIO) + : base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext, legacyIO) { } diff --git a/osu.Server.Spectator.sln.DotSettings b/osu.Server.Spectator.sln.DotSettings index 4b60715d..d3b2e19f 100644 --- a/osu.Server.Spectator.sln.DotSettings +++ b/osu.Server.Spectator.sln.DotSettings @@ -354,6 +354,7 @@ HUD ID IL + IO IOS IP IPC diff --git a/osu.Server.Spectator/AppSettings.cs b/osu.Server.Spectator/AppSettings.cs index b8598cdb..f16325db 100644 --- a/osu.Server.Spectator/AppSettings.cs +++ b/osu.Server.Spectator/AppSettings.cs @@ -34,6 +34,9 @@ public static class AppSettings public static string DatabaseUser { get; } public static string DatabasePort { get; } + public static string LegacyIODomain { get; } + public static string SharedInteropSecret { get; } + static AppSettings() { SaveReplays = Environment.GetEnvironmentVariable("SAVE_REPLAYS") == "1"; @@ -53,6 +56,9 @@ static AppSettings() DatabaseHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"; DatabaseUser = Environment.GetEnvironmentVariable("DB_USER") ?? "osuweb"; DatabasePort = Environment.GetEnvironmentVariable("DB_PORT") ?? "3306"; + + LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN") ?? string.Empty; + SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET") ?? string.Empty; } } } diff --git a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs index c1d90a31..d5243813 100644 --- a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs +++ b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using osu.Server.Spectator.Hubs.Metadata; using osu.Server.Spectator.Hubs.Multiplayer; using osu.Server.Spectator.Hubs.Spectator; +using osu.Server.Spectator.Services; using osu.Server.Spectator.Storage; using StackExchange.Redis; @@ -17,7 +18,9 @@ public static class ServiceCollectionExtensions { public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection) { - return serviceCollection.AddSingleton>() + return serviceCollection.AddHttpClient() + .AddTransient() + .AddSingleton>() .AddSingleton>() .AddSingleton>() .AddSingleton>() diff --git a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs index e2d5837e..e36eefb9 100644 --- a/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs @@ -16,6 +16,7 @@ using osu.Server.Spectator.Database.Models; using osu.Server.Spectator.Entities; using osu.Server.Spectator.Extensions; +using osu.Server.Spectator.Services; namespace osu.Server.Spectator.Hubs.Multiplayer { @@ -25,6 +26,7 @@ public class MultiplayerHub : StatefulUserHub users, IDatabaseFactory databaseFactory, ChatFilters chatFilters, - IHubContext hubContext) + IHubContext hubContext, + ILegacyIO legacyIO) : base(loggerFactory, users) { Rooms = rooms; this.databaseFactory = databaseFactory; this.chatFilters = chatFilters; + this.legacyIO = legacyIO; HubContext = new MultiplayerHubContext(hubContext, rooms, users, databaseFactory, loggerFactory); } + public async Task CreateRoom(MultiplayerRoom room) + { + Log($"{Context.GetUserId()} creating room"); + return await JoinRoomWithPassword(await legacyIO.CreateRoomAsync(Context.GetUserId(), room), room.Settings.Password); + } + public Task JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty); public async Task JoinRoomWithPassword(long roomId, string password) { Log($"Attempting to join room {roomId}"); - bool isRestricted; using (var db = databaseFactory.GetInstance()) - isRestricted = await db.IsUserRestrictedAsync(Context.GetUserId()); + { + if (await db.IsUserRestrictedAsync(Context.GetUserId())) + throw new InvalidStateException("Can't join a room when restricted."); + } - if (isRestricted) - throw new InvalidStateException("Can't join a room when restricted."); + await legacyIO.JoinRoomAsync(roomId, Context.GetUserId()); using (var userUsage = await GetOrCreateLocalUserState()) { @@ -720,8 +731,8 @@ private async Task removeDatabaseUser(MultiplayerRoom room, MultiplayerRoomUser protected override async Task CleanUpState(MultiplayerClientState state) { - await leaveRoom(state, true); await base.CleanUpState(state); + await leaveRoom(state, true); } private async Task setNewHost(MultiplayerRoom room, MultiplayerRoomUser newHost) @@ -883,6 +894,8 @@ private async Task> getLocalUserRoom(Multiplaye private async Task leaveRoom(MultiplayerClientState state, bool wasKick) { + await legacyIO.PartRoomAsync(state.CurrentRoomID, state.UserId); + using (var roomUsage = await getLocalUserRoom(state)) await leaveRoom(state, roomUsage, wasKick); } diff --git a/osu.Server.Spectator/Services/ILegacyIO.cs b/osu.Server.Spectator/Services/ILegacyIO.cs new file mode 100644 index 00000000..7741583a --- /dev/null +++ b/osu.Server.Spectator/Services/ILegacyIO.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; +using osu.Game.Online.Multiplayer; + +namespace osu.Server.Spectator.Services +{ + public interface ILegacyIO + { + /// + /// Creates an osu!web Room. + /// + /// The ID of the user that wants to create the room. + /// The room. + /// The room's ID. + Task CreateRoomAsync(int userId, MultiplayerRoom room); + + /// + /// Joins an osu!web Room. + /// + /// The ID of the room to join. + /// The ID of the user wanting to join the room. + Task JoinRoomAsync(long roomId, int userId); + + /// + /// Parts an osu!web Room. + /// + /// The ID of the room to part. + /// The ID of the user wanting to part the room. + Task PartRoomAsync(long roomId, int userId); + } +} diff --git a/osu.Server.Spectator/Services/LegacyIO.cs b/osu.Server.Spectator/Services/LegacyIO.cs new file mode 100644 index 00000000..108ca581 --- /dev/null +++ b/osu.Server.Spectator/Services/LegacyIO.cs @@ -0,0 +1,197 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; + +namespace osu.Server.Spectator.Services +{ + public class LegacyIO : ILegacyIO + { + private readonly HttpClient httpClient; + private readonly ILogger logger; + + private readonly string interopDomain; + private readonly string interopSecret; + + public LegacyIO(HttpClient httpClient, ILoggerFactory loggerFactory) + { + this.httpClient = httpClient; + logger = loggerFactory.CreateLogger("LIO"); + + interopDomain = AppSettings.LegacyIODomain + ?? throw new InvalidOperationException("LEGACY_IO_DOMAIN environment variable not set. " + + "Please set the value of this variable to the root URL of the osu-web instance to which legacy IO call should be submitted."); + interopSecret = AppSettings.SharedInteropSecret + ?? throw new InvalidOperationException("SHARED_INTEROP_SECRET environment variable not set. " + + "Please set the value of this variable to the value of the same environment variable that the target osu-web instance specifies in `.env`."); + } + + private async Task runLegacyIO(HttpMethod method, string command, dynamic? postObject = null) + { + int retryCount = 3; + + retry: + + long time = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + string url = $"{interopDomain}/_lio/{command}{(command.Contains('?') ? "&" : "?")}timestamp={time}"; + + string? serialisedPostObject; + + switch (postObject) + { + case null: + serialisedPostObject = null; + break; + + case string: + serialisedPostObject = postObject; + break; + + default: + serialisedPostObject = JsonSerializer.Serialize(postObject); + break; + } + + logger.LogDebug("Performing LIO request to {method} {url} (params: {params})", method, url, serialisedPostObject); + + try + { + string signature = hmacEncode(url, Encoding.UTF8.GetBytes(interopSecret)); + + var httpRequestMessage = new HttpRequestMessage + { + RequestUri = new Uri(url), + Method = method, + Headers = + { + { "X-LIO-Signature", signature }, + { "Accept", "application/json" }, + }, + }; + + if (serialisedPostObject != null) + { + httpRequestMessage.Content = new ByteArrayContent(Encoding.UTF8.GetBytes(serialisedPostObject)); + httpRequestMessage.Content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/json"); + } + + var response = await httpClient.SendAsync(httpRequestMessage); + + if (response.IsSuccessStatusCode) + return await response.Content.ReadAsStringAsync(); + + throw await LegacyIORequestFailedException.Create(url, response); + } + catch (Exception e) + { + if (retryCount-- > 0) + { + logger.LogError(e, "Legacy IO request to {url} failed, retrying ({retries} remaining)", url, retryCount); + Thread.Sleep(1000); + goto retry; + } + + throw; + } + } + + private static string hmacEncode(string input, byte[] key) + { + byte[] byteArray = Encoding.ASCII.GetBytes(input); + + byte[] hashArray = HMACSHA1.HashData(key, byteArray); + return hashArray.Aggregate(string.Empty, (s, e) => s + $"{e:x2}", s => s); + } + + // Methods below purposefully async-await on `runLegacyIO()` calls rather than directly returning the underlying calls. + // This is done for better readability of exception stacks. Directly returning the tasks elides the name of the proxying method. + + public async Task CreateRoomAsync(int userId, MultiplayerRoom room) + { + return long.Parse(await runLegacyIO(HttpMethod.Post, "multiplayer/rooms", Newtonsoft.Json.JsonConvert.SerializeObject(new CreateRoomRequest(room) + { + UserId = userId + }))); + } + + public async Task JoinRoomAsync(long roomId, int userId) + { + await runLegacyIO(HttpMethod.Put, $"multiplayer/rooms/{roomId}/users/{userId}"); + } + + public async Task PartRoomAsync(long roomId, int userId) + { + await runLegacyIO(HttpMethod.Delete, $"multiplayer/rooms/{roomId}/users/{userId}"); + } + + private class CreateRoomRequest : Room + { + [Newtonsoft.Json.JsonProperty("user_id")] + public required int UserId { get; init; } + + /// + /// Creates a from a . + /// + public CreateRoomRequest(MultiplayerRoom room) + { + RoomID = room.RoomID; + Host = room.Host?.User; + Name = room.Settings.Name; + Password = room.Settings.Password; + Type = room.Settings.MatchType; + QueueMode = room.Settings.QueueMode; + AutoStartDuration = room.Settings.AutoStartDuration; + AutoSkip = room.Settings.AutoSkip; + Playlist = room.Playlist.Select(item => new PlaylistItem(item)).ToArray(); + CurrentPlaylistItem = Playlist.FirstOrDefault(item => item.ID == room.Settings.PlaylistItemId); + } + } + + [Serializable] + private class LegacyIORequestFailedException : HubException + { + private LegacyIORequestFailedException(string message, Exception innerException) + : base(message, innerException) + { + } + + public static async Task Create(string url, HttpResponseMessage response) + { + string errorMessage = $"{(int)response.StatusCode}: {response.ReasonPhrase}"; + + try + { + APIErrorMessage? apiError = await JsonSerializer.DeserializeAsync(await response.Content.ReadAsStreamAsync().ConfigureAwait(false)); + if (!string.IsNullOrEmpty(apiError?.Error)) + errorMessage = apiError.Error; + } + catch + { + } + + // Outer exception message is serialised to clients, inner exception is logged to the server and NOT serialised to the client. + return new LegacyIORequestFailedException(errorMessage, new Exception($"Legacy IO request to {url} failed with {response.StatusCode} ({response.ReasonPhrase}).")); + } + + [Serializable] + private class APIErrorMessage + { + [JsonPropertyName("error")] + public string Error { get; set; } = string.Empty; + } + } + } +}