Skip to content

Commit

Permalink
Update PhotonStateManager related configuration (#865)
Browse files Browse the repository at this point in the history
- Adds support for the
[key-per-file](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration-providers#key-per-file-configuration-provider)
configuration provider to the app.
- Also fixes the lambda for adding `IPhotonStateApi` to make it pull an
updated value for the base URL.
- Miscellaneous refactoring to PhotonStateManager config, including
better use of DI and `IConfiguratioon`.

This includes several breaking changes for PhotonStateManager:
- `PHOTON_TOKEN` environment variable -> `PhotonOptions__Token`
- `ENABLE_HTTPS` environment variable no longer used, consult ASP.NET
docs for alternative

---

This is primarily intended to solve the issue of the API having to
re-template and restart each time the state manager is deployed, due to
the fact that Nomad selects a random port for the state manager.
Currently the URL is stored as an env variable which requires a
container restart to reflect a change.

Previously this has been solved by using the public URL however this
results in going through Cloudflare and Caddy again.

I could also put the state manager on a static port, but I'm not sure
whether Consul would still re-template in the 5 second window that the
service is deleted. This is also recommended against in the docs as it
makes it harder for Nomad to schedule jobs.
  • Loading branch information
SapiensAnatis authored Jun 13, 2024
1 parent 42a0a56 commit dd290f8
Show file tree
Hide file tree
Showing 14 changed files with 101 additions and 96 deletions.
4 changes: 1 addition & 3 deletions .env.default
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,5 @@ POSTGRES_DB=DragaliaAPI

# Bearer token for admin endpoints e.g. manual save import
DEVELOPER_TOKEN=
# Hostname to add to logging context
HOSTNAME=
# Token used to authenticate incoming requests from co-op Photon Server
PHOTON_TOKEN=
PhotonOptions__Token=
Original file line number Diff line number Diff line change
Expand Up @@ -1219,7 +1219,6 @@ private async Task<string> StartDungeon(DungeonSession session)

private void SetupPhotonAuthentication()
{
Environment.SetEnvironmentVariable("PHOTON_TOKEN", "supersecrettoken");
this.Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
"supersecrettoken"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using DragaliaAPI.Database;
using DragaliaAPI.Models.Options;
using DragaliaAPI.Shared.PlayerDetails;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
Expand All @@ -11,11 +12,12 @@
namespace DragaliaAPI.Middleware;

public class PhotonAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
IOptionsMonitor<AuthenticationSchemeOptions> authOptions,
IOptionsMonitor<PhotonOptions> photonOptions,
ILoggerFactory logger,
UrlEncoder encoder,
ApiContext apiContext
) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
) : AuthenticationHandler<AuthenticationSchemeOptions>(authOptions, logger, encoder)
{
public const string Role = "Photon";

Expand All @@ -40,11 +42,7 @@ out AuthenticationHeaderValue? authenticationHeader
return AuthenticateResult.NoResult();
}

string configuredToken =
Environment.GetEnvironmentVariable("PHOTON_TOKEN")
?? throw new InvalidOperationException("PHOTON_TOKEN environment variable not set!");

if (authenticationHeader.Parameter != configuredToken)
if (authenticationHeader.Parameter != photonOptions.CurrentValue.Token)
{
Logger.LogInformation(
"AuthenticationHeader.Parameter value {param} did not match configured token.",
Expand Down
6 changes: 4 additions & 2 deletions DragaliaAPI/DragaliaAPI/Models/Options/PhotonOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

public class PhotonOptions
{
public required string ServerUrl { get; set; }
public required string ServerUrl { get; init; }

public required string StateManagerUrl { get; set; }
public required string StateManagerUrl { get; init; }

public required string Token { get; init; }
}
6 changes: 6 additions & 0 deletions DragaliaAPI/DragaliaAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
reloadOnChange: true
);

string kpfPath = Path.Combine(Directory.GetCurrentDirectory(), "config");

builder.Configuration.AddKeyPerFile(directoryPath: kpfPath, optional: true, reloadOnChange: true);

builder.WebHost.UseStaticWebAssets();

builder.Logging.ClearProviders();
Expand Down Expand Up @@ -105,6 +109,8 @@

WebApplication app = builder.Build();

app.Logger.LogDebug("Using key-per-file configuration from path {KpfPath}", kpfPath);

Stopwatch watch = new();
app.Logger.LogInformation("Loading MasterAsset data.");

Expand Down
16 changes: 7 additions & 9 deletions DragaliaAPI/DragaliaAPI/ServiceConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,15 +158,13 @@ IConfiguration configuration

services.AddHttpClient<IBaasApi, BaasApi>();

services.AddHttpClient<IPhotonStateApi, PhotonStateApi>(client =>
{
PhotonOptions? options = configuration
.GetRequiredSection(nameof(PhotonOptions))
.Get<PhotonOptions>();
ArgumentNullException.ThrowIfNull(options);
client.BaseAddress = new(options.StateManagerUrl);
});
services.AddHttpClient<IPhotonStateApi, PhotonStateApi>(
(sp, client) =>
{
IOptions<PhotonOptions> options = sp.GetRequiredService<IOptions<PhotonOptions>>();
client.BaseAddress = new(options.Value.StateManagerUrl);
}
);
services.AddScoped<IMatchingService, MatchingService>();

services.AddScoped<ResourceVersionActionFilter>().AddScoped<MaintenanceActionFilter>();
Expand Down
3 changes: 3 additions & 0 deletions DragaliaAPI/DragaliaAPI/appsettings.Testing.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
"Android": "y2XM6giU6zz56wCm",
"Ios": "b1HyoeTFegeTexC0"
},
"PhotonOptions": {
"Token": "supersecrettoken"
},
"EventOptions": {
"EventList": [
{
Expand Down
8 changes: 4 additions & 4 deletions PhotonPlugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ The state manager application should be comparitively easy to deploy. It is a Do
deployed alongside a `redis/redis-stack` image. See the `docker-compose.yml` file in the repository for reference. It
does not need to be on the same server as Photon.

It expects an environment variable, `PHOTON_TOKEN`, to match that which is configured in `plugin.config` above, so as to
It expects an environment variable, `PhotonOptions__Token`, to match that which is configured in `plugin.config` above, so as to
authenticate requests from the Photon server. A sample Docker compose file could look like:

```yaml
Expand All @@ -132,7 +132,7 @@ services:
ports:
- "3000:80"
environment:
PHOTON_TOKEN: yourtoken
PhotonOptions__Token: yourtoken

redis:
hostname: redis
Expand All @@ -154,9 +154,9 @@ In the main API `appsettings.json`, configure the following values in `$.PhotonO
- `ServerUrl`: the Photon server URL. Must end with :5055 due to Dragalia using a legacy client.
- `StateManagerUrl` the Photon state manager URL.

Configure the following environment variables:
Configure the following environment variables (or other source of `IConfiguration`):

- `PHOTON_TOKEN`: the same photon token as the plugin and state manager use. Used to authenticate requests from Photon.
- `PhotonOptions__Token`: the same photon token as the plugin and state manager use. Used to authenticate requests from Photon.

#### Other servers

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;

namespace DragaliaAPI.Photon.StateManager.Test;

Expand All @@ -12,25 +13,19 @@ public CustomWebApplicationFactory()
this.testContainersHelper = new();
}

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
Environment.SetEnvironmentVariable(
"RedisOptions__Hostname",
this.testContainersHelper.RedisHost
);
Environment.SetEnvironmentVariable(
"RedisOptions__Port",
this.testContainersHelper.RedisPort.ToString()
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureAppConfiguration(cfg =>
cfg.AddInMemoryCollection(
new Dictionary<string, string?>
{
["PhotonOptions:Token"] = "photontoken",
["RedisOptions:Hostname"] = this.testContainersHelper.RedisHost,
["RedisOptions:Port"] = this.testContainersHelper.RedisPort.ToString()
}
)
);
}

public Task InitializeAsync() => this.testContainersHelper.StartAsync();

Task IAsyncLifetime.DisposeAsync() => this.testContainersHelper.StopAsync();

protected override void Dispose(bool disposing)
{
Environment.SetEnvironmentVariable("RedisOptions__Hostname", null);
Environment.SetEnvironmentVariable("RedisOptions__Port", null);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,23 @@ namespace DragaliaAPI.Photon.StateManager.Test;
[Collection(TestCollection.Name)]
public class TestFixture : IAsyncLifetime
{
private const string PhotonToken = "photontoken";

public TestFixture(CustomWebApplicationFactory factory, ITestOutputHelper outputHelper)
{
this.Client = factory
.WithWebHostBuilder(
(builder) =>
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddXUnit(outputHelper);
})
.WithWebHostBuilder(builder =>
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddXUnit(outputHelper);
})
)
.CreateClient();

this.Client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer",
PhotonToken
"photontoken"
);

Environment.SetEnvironmentVariable("PHOTON_TOKEN", PhotonToken);

this.RedisConnectionProvider =
factory.Services.GetRequiredService<IRedisConnectionProvider>();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
using System.Net.Http.Headers;
using System.Security.Claims;
using System.Text.Encodings.Web;
using DragaliaAPI.Photon.StateManager.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;

namespace DragaliaAPI.Photon.StateManager.Authentication;

public class PhotonAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
public class PhotonAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> authOptions,
IOptionsMonitor<PhotonOptions> photonOptions,
ILoggerFactory logger,
UrlEncoder encoder
) : AuthenticationHandler<AuthenticationSchemeOptions>(authOptions, logger, encoder)
{
public PhotonAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder
)
: base(options, logger, encoder) { }

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (
Expand All @@ -34,11 +33,7 @@ out AuthenticationHeaderValue? authenticationHeader
return Task.FromResult(AuthenticateResult.NoResult());
}

string configuredToken =
Environment.GetEnvironmentVariable("PHOTON_TOKEN")
?? throw new InvalidOperationException("PHOTON_TOKEN environment variable not set!");

if (authenticationHeader.Parameter != configuredToken)
if (authenticationHeader.Parameter != photonOptions.CurrentValue.Token)
{
this.Logger.LogInformation(
"AuthenticationHeader.Parameter value {param} did not match configured token.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace DragaliaAPI.Photon.StateManager.Models;

public class PhotonOptions
{
public required string Token { get; init; }
}
62 changes: 34 additions & 28 deletions PhotonStateManager/DragaliaAPI.Photon.StateManager/Program.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,15 @@
using System.Security.Cryptography.X509Certificates;
using DragaliaAPI.Photon.StateManager;
using DragaliaAPI.Photon.StateManager.Authentication;
using DragaliaAPI.Photon.StateManager.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Redis.OM;
using Redis.OM.Contracts;
using Serilog;
using StackExchange.Redis;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

if (Environment.GetEnvironmentVariable("ENABLE_HTTPS") != null)
{
X509Certificate2 certificate = X509Certificate2.CreateFromPemFile("cert.pem", "cert.key");

builder.WebHost.ConfigureKestrel(serverOptions =>
serverOptions.ConfigureHttpsDefaults(options => options.ServerCertificate = certificate)
);
}

builder.Services.AddControllers();

builder.Host.UseSerilog(
Expand All @@ -39,9 +30,20 @@
builder
.Services.AddOptions<RedisCachingOptions>()
.BindConfiguration(nameof(RedisCachingOptions))
.Validate(x => x.KeyExpiryTimeMins > 0)
.Validate(
x => x.KeyExpiryTimeMins > 0,
"RedisCachingOptions.KeyExpiryTime must be greater than 0!"
)
.ValidateOnStart();

builder
.Services.AddOptions<PhotonOptions>()
.BindConfiguration(nameof(PhotonOptions))
.Validate(x => !string.IsNullOrEmpty(x.Token), "Must specify a value for PhotonOptions.Token!")
.ValidateOnStart();

builder.Services.AddOptions<RedisOptions>().BindConfiguration(nameof(RedisOptions));

builder
.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, PhotonAuthenticationHandler>(
Expand All @@ -65,29 +67,33 @@
);
});

RedisOptions redisOptions =
builder.Configuration.GetRequiredSection(nameof(RedisOptions)).Get<RedisOptions>()
?? throw new InvalidOperationException("Failed to deserialize Redis configuration");
builder.Services.AddSingleton<IRedisConnectionProvider, RedisConnectionProvider>(sp =>
{
RedisOptions redisOptions = sp.GetRequiredService<IOptions<RedisOptions>>().Value;
Log.Logger.Information(
IConnectionMultiplexer multiplexer = ConnectionMultiplexer.Connect(
new ConfigurationOptions()
{
EndPoints = new() { { redisOptions.Hostname, redisOptions.Port } },
Password = redisOptions.Password,
AbortOnConnectFail = false,
}
);
return new RedisConnectionProvider(multiplexer);
});

WebApplication app = builder.Build();

RedisOptions redisOptions = app.Services.GetRequiredService<IOptions<RedisOptions>>().Value;

app.Logger.LogInformation(
"Connecting to Redis at {Hostname}:{Port}",
redisOptions.Hostname,
redisOptions.Port
);

IConnectionMultiplexer multiplexer = ConnectionMultiplexer.Connect(
new ConfigurationOptions()
{
EndPoints = new() { { redisOptions.Hostname, redisOptions.Port } },
Password = redisOptions.Password,
AbortOnConnectFail = false,
}
);

RedisConnectionProvider provider = new(multiplexer);
builder.Services.AddSingleton<IRedisConnectionProvider>(provider);

WebApplication app = builder.Build();
IRedisConnectionProvider provider = app.Services.GetRequiredService<IRedisConnectionProvider>();

bool created = await provider.Connection.CreateIndexAsync(typeof(RedisGame));
RedisIndexInfo? info = await provider.Connection.GetIndexInfoAsync(typeof(RedisGame));
Expand Down
6 changes: 5 additions & 1 deletion PhotonStateManager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ It operates by receiving webhook events from the Photon server, which trigger ro

PhotonStateManager exposes a very simple REST API that is divided into two halves: 'private' endpoints designed for consumption by the Photon server, and 'public' endpoints that are designed to be consumed by the main Dragalia Lost API server. The private endpoints are under the /event/ route group, and the public endpoints are under the /get/ route group.

The private endpoints are secured by bearer token authentication set by an environment variable `PHOTON_TOKEN`.
The private endpoints are secured by bearer token authentication set by an IConfiguration property `PhotonOptions.Token`. The simplest way to configure this is by setting the environment variable `PhotonOptions__Token`.

### /get/ endpoints

- `/get/gamelist`: Returns a list of currently open games that are available to join for public matchmaking.
- `/get/byid/{roomId}`: Searches for a room by its numeric passcode. This can include private rooms as well. If found, returns 200 OK, otherwise 404 Not Found.
- `/get/ishost/{viewerId}`: Returns a scalar boolean indicating whether a user with the provided player ID is a host in any room.
- `/get/byviewerid/{viewerId}`: Returns the room that a player is in, or 404 if they could not be found in a room.

## HTTPS

To configure the container to accept requests over HTTPS, follow the instructions in the [Microsoft documentation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-8.0#configure-https-in-appsettingsjson).

0 comments on commit dd290f8

Please sign in to comment.