Skip to content

Commit

Permalink
Add presence server (#652)
Browse files Browse the repository at this point in the history
This server is responsible for one thing only, which is telling the
client to load the slot listing for any user slot you send it. Retail
LBP2 and LBP3 both implement this, but both require [special
care](https://discord.com/channels/1049223665243389953/1049225857350254632/1280019755482218588)
to make it use a separate domain (which is important if you want to
protect your main gameserver behind cloudflare!)

This is a fairly basic implementation of the server, but should be
sufficient for even large amounts of connected players.

Marking as draft until #649 is merged and hashed live play now is added,
since this branch is based off of that one (didnt wanna deal with merge
conflicts on this PR).

Thanks to aidan for figuring all this out

Example of server in action:


https://github.com/user-attachments/assets/d790a570-c291-4005-b3cf-daf6a95a9f51
  • Loading branch information
jvyden authored Sep 10, 2024
2 parents 28bdb00 + 15abd9f commit 0cacb1c
Show file tree
Hide file tree
Showing 37 changed files with 1,035 additions and 50 deletions.
6 changes: 6 additions & 0 deletions Refresh.Common/Constants/EndpointRoutes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Refresh.Common.Constants;

public static class EndpointRoutes
{
public const string PresenceBaseRoute = "/_internal/presence/";
}
3 changes: 3 additions & 0 deletions Refresh.Common/Constants/SystemUsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ public static class SystemUsers

public const string UnknownUserName = "!Unknown";
public const string UnknownUserDescription = "I'm a fake user that represents a non existent publisher for re-published levels.";

public const string HashedUserName = "!Hashed";
public const string HashedUserDescription = "I'm a fake user that represents an unknown publisher for hashed levels.";
}
110 changes: 110 additions & 0 deletions Refresh.Common/Helpers/ResourceHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using System.Buffers.Binary;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using FastAes;
using IronCompress;
Expand Down Expand Up @@ -107,4 +109,112 @@ public static byte[] PspDecrypt(Span<byte> data, ReadOnlySpan<byte> key)
//Return a copy of the decompressed data
return decompressed.AsSpan().ToArray();
}

static int XXTEA_DELTA = Unsafe.BitCast<uint, int>(0x9e3779b9);

/// <summary>
/// In-place encrypts byte data using big endian XXTEA.
///
/// Due to how XXTEA data works, you must pad the data to a multiple of 4 bytes.
/// </summary>
/// <param name="byteData">The data to encrypt</param>
/// <param name="key">The key used to encrypt the data</param>
/// <exception cref="ArgumentException">The input is not a multiple of 4 bytes</exception>
/// <remarks>
/// Referenced from https://github.com/ennuo/toolkit/blob/dc82bee57ab58e9f4bf35993d405529d4cbc7d00/lib/cwlib/src/main/java/cwlib/util/Crypto.java#L97
/// </remarks>
public static void XxteaEncrypt(Span<byte> byteData, Span<int> key)
{
if (byteData.Length % 4 != 0)
throw new ArgumentException("Data must be padded to a multiple of 4 bytes.", nameof(byteData));

// Alias the byte data as integers
Span<int> data = MemoryMarshal.Cast<byte, int>(byteData);

// endian swap from BE so the math happens in LE space
BinaryPrimitives.ReverseEndianness(data, data);

int n = data.Length - 1;
if (n < 1)
{
BinaryPrimitives.ReverseEndianness(data, data);

return;
}

int p, q = 6 + 52 / (n + 1);

int z = data[n], y, sum = 0, e;
while (q-- > 0)
{
sum += XXTEA_DELTA;
e = sum >>> 2 & 3;
for (p = 0; p < n; p++)
{
y = data[p + 1];
z =
data[p] += ((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
}

y = data[0];
z =
data[n] += ((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
}

// endian swap so the final data is in LE again
BinaryPrimitives.ReverseEndianness(data, data);
}

/// <summary>
/// In-place decrypts byte data using big endian XXTEA.
///
/// Due to how XXTEA data works, you must pad the data to a multiple of 4 bytes.
/// </summary>
/// <param name="byteData">The data to decrypt</param>
/// <param name="key">The key used to decrypt the data</param>
/// <exception cref="ArgumentException">The input is not a multiple of 4 bytes</exception>
/// <remarks>
/// Referenced from https://github.com/ennuo/toolkit/blob/dc82bee57ab58e9f4bf35993d405529d4cbc7d00/lib/cwlib/src/main/java/cwlib/util/Crypto.java#L97
/// </remarks>
public static void XxteaDecrypt(Span<byte> byteData, Span<int> key)
{
if (byteData.Length % 4 != 0)
throw new ArgumentException("Data must be padded to 4 bytes.", nameof(byteData));

// Alias the byte data as integers
Span<int> data = MemoryMarshal.Cast<byte, int>(byteData);

// endian swap from BE so the math happens in LE space
BinaryPrimitives.ReverseEndianness(data, data);

int n = data.Length - 1;
if (n < 1)
{
BinaryPrimitives.ReverseEndianness(data, data);

return;
}

int p, q = 6 + 52 / (n + 1);

int z, y = data[0], sum = q * XXTEA_DELTA, e;
while (sum != 0)
{
e = sum >>> 2 & 3;
for (p = n; p > 0; p--)
{
z = data[p - 1];
y = data[p] -=
((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
}

z = data[n];
y =
data[0] -= ((z >>> 5 ^ y << 2) + (y >>> 3 ^ z << 4) ^ (sum ^ y) + (key[p & 3 ^ e] ^ z));
sum -= XXTEA_DELTA;
}

// endian swap so the final data is in LE again
BinaryPrimitives.ReverseEndianness(data, data);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ public GameAuthenticationProvider(GameServerConfig? config)

public Token? AuthenticateToken(ListenerContext request, Lazy<IDatabaseContext> db)
{
// Dont attempt to authenticate presence endpoints, as authentication is handled by PresenceAuthenticationMiddleware
if (request.Uri.AbsolutePath.StartsWith(PresenceEndpointAttribute.BaseRoute))
return null;

// First try to grab game token data from MM_AUTH
string? tokenData = request.Cookies["MM_AUTH"];
TokenType tokenType = TokenType.Game;
Expand Down
12 changes: 11 additions & 1 deletion Refresh.GameServer/Configuration/IntegrationConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Refresh.GameServer.Configuration;
/// </summary>
public class IntegrationConfig : Config
{
public override int CurrentConfigVersion => 5;
public override int CurrentConfigVersion => 6;
public override int Version { get; set; }
protected override void Migrate(int oldVer, dynamic oldConfig)
{
Expand Down Expand Up @@ -56,6 +56,16 @@ protected override void Migrate(int oldVer, dynamic oldConfig)

public bool AipiRestrictAccountOnDetection { get; set; } = false;

#endregion

#region Presence

public bool PresenceEnabled { get; set; } = false;

public string PresenceBaseUrl { get; set; } = "http://localhost:10073";

public string PresenceSharedSecret { get; set; } = "SHARED_SECRET";

#endregion

public string? GrafanaDashboardUrl { get; set; }
Expand Down
8 changes: 8 additions & 0 deletions Refresh.GameServer/Database/GameDatabaseContext.Users.cs
Original file line number Diff line number Diff line change
Expand Up @@ -455,4 +455,12 @@ public void SetUserRootPlaylist(GameUser user, GamePlaylist playlist)
user.RootPlaylist = playlist;
});
}

public void SetUserPresenceAuthToken(GameUser user, string? token)
{
this.Write(() =>
{
user.PresenceServerAuthToken = token;
});
}
}
2 changes: 1 addition & 1 deletion Refresh.GameServer/Database/GameDatabaseProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ protected GameDatabaseProvider(IDateTimeProvider time)
this._time = time;
}

protected override ulong SchemaVersion => 156;
protected override ulong SchemaVersion => 159;

protected override string Filename => "refreshGameServer.realm";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap

public required ApiGameUserStatisticsResponse Statistics { get; set; }
public required ApiGameRoomResponse? ActiveRoom { get; set; }
public required bool ConnectedToPresenceServer { get; set; }

[ContractAnnotation("null => null; notnull => notnull")]
[ContractAnnotation("user:null => null; user:notnull => notnull")]
public static ApiExtendedGameUserResponse? FromOld(GameUser? user, DataContext dataContext)
{
if (user == null) return null;
Expand Down Expand Up @@ -77,6 +78,7 @@ public class ApiExtendedGameUserResponse : IApiResponse, IDataConvertableFrom<Ap
LevelVisibility = user.LevelVisibility,
ProfileVisibility = user.ProfileVisibility,
ShowModdedContent = user.ShowModdedContent,
ConnectedToPresenceServer = user.PresenceServerAuthToken != null,
};
}

Expand Down
18 changes: 12 additions & 6 deletions Refresh.GameServer/Endpoints/ApiV3/LevelApiEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using Refresh.GameServer.Endpoints.ApiV3.ApiTypes.Errors;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Request;
using Refresh.GameServer.Endpoints.ApiV3.DataTypes.Response.Levels;
using Refresh.GameServer.Endpoints.Game.DataTypes.Response;
using Refresh.GameServer.Endpoints.Game.Levels.FilterSettings;
using Refresh.GameServer.Extensions;
using Refresh.GameServer.Services;
Expand Down Expand Up @@ -144,13 +145,17 @@ public ApiOkResponse DeleteLevelById(RequestContext context, GameDatabaseContext
[ApiV3Endpoint("levels/id/{id}/setAsOverride", HttpMethods.Post)]
[DocSummary("Marks the level to show in the next slot list gotten from the game")]
[DocError(typeof(ApiNotFoundError), ApiNotFoundError.LevelMissingErrorWhen)]
public ApiOkResponse SetLevelAsOverrideById(RequestContext context, GameDatabaseContext database, GameUser user, LevelListOverrideService service,
public ApiOkResponse SetLevelAsOverrideById(RequestContext context,
GameDatabaseContext database,
GameUser user,
PlayNowService overrideService,
[DocSummary("The ID of the level")] int id)
{
GameLevel? level = database.GetLevelById(id);
if (level == null) return ApiNotFoundError.LevelMissingError;

service.AddIdOverridesForUser(user, level);

// TODO: return whether or not the presence server was used
overrideService.PlayNowLevel(user, level);

return new ApiOkResponse();
}
Expand All @@ -159,12 +164,13 @@ public ApiOkResponse SetLevelAsOverrideById(RequestContext context, GameDatabase
[DocSummary("Marks the level hash to show in the next slot list gotten from the game")]
[DocError(typeof(ApiValidationError), ApiValidationError.HashInvalidErrorWhen)]
public ApiOkResponse SetLevelAsOverrideByHash(RequestContext context, GameDatabaseContext database, GameUser user,
LevelListOverrideService service, [DocSummary("The hash of level root resource")] string hash)
PlayNowService service, PresenceService presenceService, [DocSummary("The hash of level root resource")] string hash)
{
if (!CommonPatterns.Sha1Regex().IsMatch(hash))
return ApiValidationError.HashInvalidError;

service.AddHashOverrideForUser(user, hash);

// TODO: return whether presence/hash play now was used
service.PlayNowHash(user, hash);

return new ApiOkResponse();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ public static GameLevelResponse FromHash(string hash, DataContext dataContext)
Location = new GameLocation(),
Handle = new SerializedUserHandle
{
Username = $"!Hashed",
Username = SystemUsers.HashedUserName,
IconHash = "0",
},
Type = GameSlotType.User.ToGameType(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,12 @@ public class AuthenticationEndpoints : EndpointGroup

Token token = database.GenerateTokenForUser(user, TokenType.Game, game.Value, platform.Value, context.RemoteIp(), GameDatabaseContext.GameTokenExpirySeconds); // 4 hours

//Clear the user's force match
// Clear the user's force match
database.ClearForceMatch(user);

// Mark the user as disconnected from the presence server
database.SetUserPresenceAuthToken(user, null);

context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} successfully logged in on {game} via {platform}");

if (game == TokenGame.LittleBigPlanetPSP)
Expand Down Expand Up @@ -278,6 +281,8 @@ public Response RevokeThisToken(RequestContext context, GameDatabaseContext data

// Revoke the token
database.RevokeToken(token);
// Mark them as disconnected from the presence server
database.SetUserPresenceAuthToken(user, null);

context.Logger.LogInfo(BunkumCategory.Authentication, $"{user} logged out");
return OK;
Expand Down
14 changes: 7 additions & 7 deletions Refresh.GameServer/Endpoints/Game/Levels/LevelEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using Bunkum.Core;
using Bunkum.Core.Endpoints;
using Bunkum.Core.Endpoints.Debugging;
using Bunkum.Core.Storage;
using Bunkum.Listener.Protocol;
using Refresh.Common.Constants;
using Refresh.GameServer.Authentication;
using Refresh.GameServer.Database;
using Refresh.GameServer.Endpoints.Game.DataTypes.Response;
Expand All @@ -26,7 +26,7 @@ public class LevelEndpoints : EndpointGroup
public SerializedMinimalLevelList? GetLevels(RequestContext context,
GameDatabaseContext database,
CategoryService categoryService,
LevelListOverrideService overrideService,
PlayNowService overrideService,
GameUser user,
Token token,
DataContext dataContext,
Expand All @@ -47,7 +47,7 @@ public class LevelEndpoints : EndpointGroup

// If we are getting the levels by a user, and that user is "!Hashed", then we pull that user's overrides
if (route == "by"
&& (context.QueryString.Get("u") == "!Hashed" || user.Username == "!Hashed")
&& (context.QueryString.Get("u") == SystemUsers.HashedUserName || user.Username == SystemUsers.HashedUserName)
&& overrideService.GetLastHashOverrideForUser(token, out string hash))
{
return new SerializedMinimalLevelList
Expand Down Expand Up @@ -101,7 +101,7 @@ public class LevelEndpoints : EndpointGroup
public SerializedMinimalLevelList? GetLevelsWithPlayer(RequestContext context,
GameDatabaseContext database,
CategoryService categories,
LevelListOverrideService overrideService,
PlayNowService overrideService,
Token token,
DataContext dataContext,
string route,
Expand All @@ -118,7 +118,7 @@ public class LevelEndpoints : EndpointGroup
[MinimumRole(GameUserRole.Restricted)]
public GameLevelResponse? LevelById(RequestContext context, GameDatabaseContext database, Token token,
string slotType, int id,
LevelListOverrideService overrideService, DataContext dataContext)
PlayNowService overrideService, DataContext dataContext)
{
// If the user has had a hash override in the past, and the level id they requested matches the level ID associated with that hash
if (overrideService.GetLastHashOverrideForUser(token, out string hash) && GameLevelResponse.LevelIdFromHash(hash) == id)
Expand Down Expand Up @@ -204,7 +204,7 @@ public SerializedMinimalLevelResultsList GetLevelsFromCategory(RequestContext co
GameDatabaseContext database,
CategoryService categories,
MatchService matchService,
LevelListOverrideService overrideService,
PlayNowService overrideService,
GameUser user,
IDataStore dataStore,
Token token,
Expand All @@ -218,7 +218,7 @@ public SerializedMinimalLevelResultsList GetLevelsFromCategory(RequestContext co
GameDatabaseContext database,
CategoryService categories,
MatchService matchService,
LevelListOverrideService overrideService,
PlayNowService overrideService,
Token token,
IDataStore dataStore,
DataContext dataContext,
Expand Down
Loading

0 comments on commit 0cacb1c

Please sign in to comment.