Skip to content

Commit 7e4e60b

Browse files
hhvrcLucHeart
andcommitted
feat: GeoIP session enrichment and LCG assignment
Add an IP enrichment service backed by the MaxMind GeoLite2 ASN and City databases, configured under OpenShock:Geo (AsnDbPath, CityDbPath). When neither database is available the service returns null and callers fall back to the previous behavior. Login sessions: - Record ASN organization, VPN flag, country code and city when a session is created, and expose them on LoginSessionResponse. - IsVpn is nullable so a failed or missing ASN lookup stays unknown rather than being reported as "not VPN". - VPN detection matches whole tokens against consumer VPN providers only, so datacenter/cloud egress is not flagged and substrings like "pia" in "Olympia" don't match. Gateway assignment: - LCGs can advertise an optional Latitude/Longitude, published on the LcgNode in Redis. - Hub assignment still picks the closest region by country, then narrows it by great-circle distance to the hub's GeoIP location when every node in that region has coordinates. - LcgKeepAlive now overwrites the whole node on every beat instead of diffing fields, which also backfills nodes written by older builds. A default GeoOptions is registered in AddOpenShockServices so hosts that don't configure Geo (Cron, LCG, SeedE2E) still resolve the service. Co-authored-by: LucHeart <luc@luc.cat>
1 parent 1742e06 commit 7e4e60b

27 files changed

Lines changed: 695 additions & 63 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using System.Net;
2+
using Microsoft.AspNetCore.Hosting;
3+
using Microsoft.AspNetCore.TestHost;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.DependencyInjection.Extensions;
6+
using OpenShock.Common.Services.Geo;
7+
8+
namespace OpenShock.API.IntegrationTests;
9+
10+
/// <summary>
11+
/// Variant of <see cref="WebApplicationFactory"/> whose GeoIP lookup resolves every request to
12+
/// <see cref="ClientLocation"/>, so tests can place the caller without shipping a GeoLite2 database.
13+
/// </summary>
14+
public sealed class GeoLocatedWebApplicationFactory : WebApplicationFactory
15+
{
16+
private readonly FakeIpEnrichmentService _enrichment = new();
17+
18+
public GeoPoint? ClientLocation
19+
{
20+
get => _enrichment.Location;
21+
set => _enrichment.Location = value;
22+
}
23+
24+
protected override void ConfigureWebHost(IWebHostBuilder builder)
25+
{
26+
base.ConfigureWebHost(builder);
27+
28+
builder.ConfigureTestServices(services =>
29+
{
30+
services.RemoveAll<IIpEnrichmentService>();
31+
services.AddSingleton<IIpEnrichmentService>(_enrichment);
32+
});
33+
}
34+
35+
private sealed class FakeIpEnrichmentService : IIpEnrichmentService
36+
{
37+
public GeoPoint? Location { get; set; }
38+
39+
public IpEnrichmentData? Enrich(IPAddress ip) =>
40+
Location is { } location ? new IpEnrichmentData(null, null, null, null, location, 20) : null;
41+
}
42+
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
using System.Net;
2+
using System.Net.Http.Json;
3+
using Microsoft.AspNetCore.Hosting;
4+
using Microsoft.EntityFrameworkCore;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using OpenShock.API.Models.Response;
7+
using OpenShock.Common.OpenShockDb;
8+
using OpenShock.Common.Redis;
9+
using OpenShock.Common.Services.Geo;
10+
using OpenShock.Common.Utils;
11+
using OpenShock.Internal.Common.Utils;
12+
using Redis.OM.Contracts;
13+
14+
namespace OpenShock.API.IntegrationTests.Tests;
15+
16+
public sealed class LcgCoordinateAssignmentTests
17+
{
18+
[ClassDataSource<GeoLocatedWebApplicationFactory>(Shared = SharedType.PerTestSession)]
19+
public required GeoLocatedWebApplicationFactory WebApplicationFactory { get; init; }
20+
21+
// Shares the key with LcgAssignmentTests: both suites wipe the same Redis node collection.
22+
private const string ParalellGateway = "gateway_assignment";
23+
24+
private static readonly GeoPoint Seattle = new(47.61, -122.33);
25+
private static readonly GeoPoint NewYork = new(40.71, -74.01);
26+
private static readonly GeoPoint Oregon = new(45.60, -121.18);
27+
private static readonly GeoPoint Virginia = new(39.04, -77.49);
28+
private static readonly GeoPoint Frankfurt = new(50.11, 8.68);
29+
private static readonly GeoPoint Munich = new(48.14, 11.58);
30+
private static readonly GeoPoint Hamburg = new(53.55, 9.99);
31+
private static readonly GeoPoint Vienna = new(48.21, 16.37);
32+
33+
private Guid _userId;
34+
private Guid _hubId;
35+
private string _hubToken = string.Empty;
36+
37+
[Before(Test)]
38+
public async Task Setup()
39+
{
40+
await using var context = WebApplicationFactory.Services.CreateAsyncScope();
41+
var db = context.ServiceProvider.GetRequiredService<OpenShockContext>();
42+
43+
_userId = Guid.CreateVersion7();
44+
_hubId = Guid.CreateVersion7();
45+
_hubToken = CryptoUtils.RandomString(256);
46+
47+
db.Users.Add(new User
48+
{
49+
Id = _userId,
50+
Name = _userId.ToString("N"),
51+
Email = $"{_userId}@test.org",
52+
PasswordHash = HashingUtils.HashPassword("password")
53+
});
54+
db.Devices.Add(new Device
55+
{
56+
Id = _hubId,
57+
Name = "TestHub",
58+
OwnerId = _userId,
59+
Token = _hubToken,
60+
CreatedAt = DateTime.UtcNow
61+
});
62+
await db.SaveChangesAsync();
63+
}
64+
65+
[After(Test)]
66+
public async Task Teardown()
67+
{
68+
WebApplicationFactory.ClientLocation = null;
69+
70+
await using var context = WebApplicationFactory.Services.CreateAsyncScope();
71+
var db = context.ServiceProvider.GetRequiredService<OpenShockContext>();
72+
var lcgNodesCollection = context.ServiceProvider.GetRequiredService<IRedisConnectionProvider>().RedisCollection<LcgNode>(false);
73+
74+
await db.Devices.Where(x => x.Id == _hubId).ExecuteDeleteAsync();
75+
await db.Users.Where(x => x.Id == _userId).ExecuteDeleteAsync();
76+
77+
var allLcg = await lcgNodesCollection.ToListAsync();
78+
await lcgNodesCollection.DeleteAsync(allLcg);
79+
}
80+
81+
[Test]
82+
[NotInParallel(ParalellGateway)]
83+
public async Task PicksClosestGatewayWithinSameCountry()
84+
{
85+
// Both gateways are "US", so the country table alone ties them; coordinates break the tie.
86+
await AddGateway("us-west.example.com", "US", Oregon);
87+
await AddGateway("us-east.example.com", "US", Virginia);
88+
89+
WebApplicationFactory.ClientLocation = Seattle;
90+
await Assert.That(await AssignHost("US")).IsEqualTo("us-west.example.com");
91+
92+
WebApplicationFactory.ClientLocation = NewYork;
93+
await Assert.That(await AssignHost("US")).IsEqualTo("us-east.example.com");
94+
}
95+
96+
[Test]
97+
[NotInParallel(ParalellGateway)]
98+
public async Task UsesCoordinatesWithoutCountryHeader()
99+
{
100+
await AddGateway("us-west.example.com", "US", Oregon);
101+
await AddGateway("de1.example.com", "DE", Frankfurt);
102+
103+
WebApplicationFactory.ClientLocation = Seattle;
104+
await Assert.That(await AssignHost(null)).IsEqualTo("us-west.example.com");
105+
}
106+
107+
[Test]
108+
[NotInParallel(ParalellGateway)]
109+
public async Task FallsBackToCountryWhenClientHasNoLocation()
110+
{
111+
await AddGateway("us-west.example.com", "US", Oregon);
112+
await AddGateway("de1.example.com", "DE", Frankfurt);
113+
114+
WebApplicationFactory.ClientLocation = null;
115+
await Assert.That(await AssignHost("DE")).IsEqualTo("de1.example.com");
116+
}
117+
118+
[Test]
119+
[NotInParallel(ParalellGateway)]
120+
public async Task FallsBackToCountryForGatewayWithoutLocation()
121+
{
122+
// A gateway that has not configured coordinates must still be assignable by country.
123+
await AddGateway("us-west.example.com", "US", Oregon);
124+
await AddGateway("de1.example.com", "DE", null);
125+
126+
WebApplicationFactory.ClientLocation = Frankfurt;
127+
await Assert.That(await AssignHost("DE")).IsEqualTo("de1.example.com");
128+
}
129+
130+
[Test]
131+
[NotInParallel(ParalellGateway)]
132+
public async Task CoordinatesOverrideLoadWithinRegion()
133+
{
134+
// Baseline for MixedCoordinatesLeaveRegionUntouched: with full coordinates, distance wins over load.
135+
await AddGateway("us-west.example.com", "US", Oregon, load: 5);
136+
await AddGateway("us-east.example.com", "US", Virginia, load: 0);
137+
138+
WebApplicationFactory.ClientLocation = Seattle;
139+
await Assert.That(await AssignHost("US")).IsEqualTo("us-west.example.com");
140+
}
141+
142+
[Test]
143+
[NotInParallel(ParalellGateway)]
144+
public async Task MixedCoordinatesLeaveRegionUntouched()
145+
{
146+
// us-east has no coordinates, so the US region can't be ranked fairly and must fall back to
147+
// pure load balancing, rather than us-east winning on a 0 km same-country score.
148+
await AddGateway("us-west.example.com", "US", Oregon, load: 0);
149+
await AddGateway("us-east.example.com", "US", null, load: 5);
150+
151+
WebApplicationFactory.ClientLocation = NewYork;
152+
await Assert.That(await AssignHost("US")).IsEqualTo("us-west.example.com");
153+
}
154+
155+
[Test]
156+
[NotInParallel(ParalellGateway)]
157+
public async Task CoordinatesDoNotCrossCountryPick()
158+
{
159+
// Munich is nearer Vienna than Hamburg, but coordinates only refine the country pick.
160+
await AddGateway("de1.example.com", "DE", Hamburg);
161+
await AddGateway("at1.example.com", "AT", Vienna);
162+
163+
WebApplicationFactory.ClientLocation = Munich;
164+
await Assert.That(await AssignHost("DE")).IsEqualTo("de1.example.com");
165+
}
166+
167+
private async Task AddGateway(string host, string country, GeoPoint? location, byte load = 0)
168+
{
169+
await using var context = WebApplicationFactory.Services.CreateAsyncScope();
170+
var environment = context.ServiceProvider.GetRequiredService<IWebHostEnvironment>();
171+
var lcgNodesCollection = context.ServiceProvider.GetRequiredService<IRedisConnectionProvider>().RedisCollection<LcgNode>(false);
172+
173+
await lcgNodesCollection.InsertAsync(new LcgNode
174+
{
175+
Id = host,
176+
Host = host,
177+
Port = 443,
178+
Country = country,
179+
Latitude = location?.Latitude,
180+
Longitude = location?.Longitude,
181+
Load = load,
182+
Environment = environment.EnvironmentName
183+
});
184+
}
185+
186+
private async Task<string> AssignHost(string? requesterCountry)
187+
{
188+
var httpRequest = new HttpRequestMessage(HttpMethod.Get, "/2/device/assignLCG?version=2");
189+
httpRequest.Headers.Add("Device-Token", _hubToken);
190+
if (!string.IsNullOrEmpty(requesterCountry)) httpRequest.Headers.Add("CF-IPCountry", requesterCountry);
191+
192+
using var client = WebApplicationFactory.CreateClient();
193+
using var response = await client.SendAsync(httpRequest);
194+
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
195+
196+
var data = await response.Content.ReadFromJsonAsync<LcgNodeResponseV2>();
197+
await Assert.That(data).IsNotNull();
198+
return data!.Host;
199+
}
200+
}

‎API/Controller/Device/AssignLCG.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using OpenShock.API.Services.LCGNodeProvisioner;
66
using OpenShock.Common.Errors;
77
using OpenShock.Common.Problems;
8+
using OpenShock.Common.Services.Geo;
89
using OpenShock.Common.Utils;
910
using OpenShock.Common.Models;
1011

@@ -23,15 +24,15 @@ public sealed partial class DeviceController
2324
[MapToApiVersion("1")]
2425
[ProducesResponseType<LegacyDataResponse<LcgNodeResponse>>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
2526
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status503ServiceUnavailable, MediaTypeNames.Application.ProblemJson)] // NoLcgNodesAvailable
26-
public async Task<IActionResult> GetLiveControlGateway([FromServices] ILCGNodeProvisioner geoLocation)
27+
public async Task<IActionResult> GetLiveControlGateway([FromServices] ILCGNodeProvisioner geoLocation, [FromServices] IIpEnrichmentService ipEnrichment)
2728
{
2829
if (!HttpContext.TryGetCFIPCountryCode(out var countryCode))
2930
{
3031
_logger.LogWarning("CF-IPCountry header could not be parsed into a alpha2 country code");
3132
}
3233

3334
try {
34-
var closestNode = await geoLocation.GetOptimalNodeAsync(countryCode);
35+
var closestNode = await geoLocation.GetOptimalNodeAsync(countryCode, ipEnrichment.Enrich(HttpContext.GetRemoteIP())?.Location);
3536
if (closestNode is null) return Problem(AssignLcgError.NoLcgNodesAvailable);
3637

3738
return LegacyDataOk(new LcgNodeResponse

‎API/Controller/Device/AssignLCGV2.cs‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using OpenShock.API.Services.LCGNodeProvisioner;
66
using OpenShock.Common.Errors;
77
using OpenShock.Common.Problems;
8+
using OpenShock.Common.Services.Geo;
89
using OpenShock.Common.Utils;
910

1011
using OpenShock.Internal.Common.Problems;
@@ -23,7 +24,7 @@ public sealed partial class DeviceController
2324
[ProducesResponseType<LcgNodeResponseV2>(StatusCodes.Status200OK, MediaTypeNames.Application.Json)]
2425
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status400BadRequest, MediaTypeNames.Application.ProblemJson)] // BadSchemaVersion
2526
[ProducesResponseType<OpenShockProblem>(StatusCodes.Status503ServiceUnavailable, MediaTypeNames.Application.ProblemJson)] // NoLcgNodesAvailable
26-
public async Task<IActionResult> GetLiveControlGatewayV2([FromQuery(Name = "version")] uint version, [FromServices] ILCGNodeProvisioner geoLocation)
27+
public async Task<IActionResult> GetLiveControlGatewayV2([FromQuery(Name = "version")] uint version, [FromServices] ILCGNodeProvisioner geoLocation, [FromServices] IIpEnrichmentService ipEnrichment)
2728
{
2829
string path;
2930
switch (version)
@@ -43,7 +44,7 @@ public async Task<IActionResult> GetLiveControlGatewayV2([FromQuery(Name = "vers
4344
_logger.LogWarning("CF-IPCountry header could not be parsed into a alpha2 country code");
4445
}
4546

46-
var closestNode = await geoLocation.GetOptimalNodeAsync(countryCode);
47+
var closestNode = await geoLocation.GetOptimalNodeAsync(countryCode, ipEnrichment.Enrich(HttpContext.GetRemoteIP())?.Location);
4748
if (closestNode is null) return Problem(AssignLcgError.NoLcgNodesAvailable);
4849

4950
return Ok(new LcgNodeResponseV2

‎API/Models/Response/LoginSessionResponse.cs‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ public static LoginSessionResponse MapFrom(LoginSession session)
1313
UserAgent = session.UserAgent,
1414
Created = session.Created!.Value,
1515
Expires = session.Expires!.Value,
16-
LastUsed = session.LastUsed
16+
LastUsed = session.LastUsed,
17+
AsnOrg = session.AsnOrg,
18+
IsVpn = session.IsVpn,
19+
CountryCode = session.CountryCode,
20+
City = session.City,
1721
};
1822
}
1923

@@ -23,4 +27,8 @@ public static LoginSessionResponse MapFrom(LoginSession session)
2327
public required DateTimeOffset Created { get; init; }
2428
public required DateTimeOffset Expires { get; init; }
2529
public required DateTimeOffset? LastUsed { get; init; }
26-
}
30+
public string? AsnOrg { get; init; }
31+
public bool? IsVpn { get; init; }
32+
public string? CountryCode { get; init; }
33+
public string? City { get; init; }
34+
}

‎API/Program.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
var databaseOptions = builder.RegisterDatabaseOptions();
2727
builder.RegisterMetricsOptions();
2828
builder.RegisterFrontendOptions();
29+
builder.RegisterGeoOptions();
2930
builder.RegisterAccountOptions();
3031
// The API never sends mail, but it must know whether anything ever will: with mail disabled there is
3132
// no activation link, so accounts are activated on creation instead of waiting for one.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
using OpenShock.Internal.Common.Geo;
22
using OpenShock.Common.Redis;
3+
using OpenShock.Common.Services.Geo;
34

45
namespace OpenShock.API.Services.LCGNodeProvisioner;
56

67
public interface ILCGNodeProvisioner
78
{
89
public Task<LcgNode?> GetOptimalNodeAsync();
910
public Task<LcgNode?> GetOptimalNodeAsync(Alpha2CountryCode countryCode);
11+
12+
/// <summary>
13+
/// Picks the closest, least loaded node. The closest region is chosen by country; when
14+
/// <paramref name="clientLocation"/> is known and every node in that region has coordinates, the
15+
/// region is further narrowed to the nodes nearest the client.
16+
/// </summary>
17+
public Task<LcgNode?> GetOptimalNodeAsync(Alpha2CountryCode countryCode, GeoPoint? clientLocation);
1018
}

0 commit comments

Comments
 (0)