diff --git a/API/Models/Response/LoginSessionResponse.cs b/API/Models/Response/LoginSessionResponse.cs
index 8033d14a..98bb57a3 100644
--- a/API/Models/Response/LoginSessionResponse.cs
+++ b/API/Models/Response/LoginSessionResponse.cs
@@ -13,7 +13,11 @@ public static LoginSessionResponse MapFrom(LoginSession session)
UserAgent = session.UserAgent,
Created = session.Created!.Value,
Expires = session.Expires!.Value,
- LastUsed = session.LastUsed
+ LastUsed = session.LastUsed,
+ AsnOrg = session.AsnOrg,
+ IsVpn = session.IsVpn,
+ CountryCode = session.CountryCode,
+ City = session.City,
};
}
@@ -23,4 +27,8 @@ public static LoginSessionResponse MapFrom(LoginSession session)
public required DateTimeOffset Created { get; init; }
public required DateTimeOffset Expires { get; init; }
public required DateTimeOffset? LastUsed { get; init; }
+ public string? AsnOrg { get; init; }
+ public bool? IsVpn { get; init; }
+ public string? CountryCode { get; init; }
+ public string? City { get; init; }
}
\ No newline at end of file
diff --git a/API/Program.cs b/API/Program.cs
index c5bfa067..a04fde55 100644
--- a/API/Program.cs
+++ b/API/Program.cs
@@ -25,6 +25,7 @@
var databaseOptions = builder.RegisterDatabaseOptions();
builder.RegisterMetricsOptions();
builder.RegisterFrontendOptions();
+builder.RegisterGeoOptions();
builder.RegisterAccountOptions();
// The API never sends mail, but it must know whether anything ever will: with mail disabled there is
// no activation link, so accounts are activated on creation instead of waiting for one.
diff --git a/Common/Common.csproj b/Common/Common.csproj
index c920230c..16efce5e 100644
--- a/Common/Common.csproj
+++ b/Common/Common.csproj
@@ -9,6 +9,7 @@
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/Common/Extensions/ConfigurationExtensions.cs b/Common/Extensions/ConfigurationExtensions.cs
index 3522baf8..020b6169 100644
--- a/Common/Extensions/ConfigurationExtensions.cs
+++ b/Common/Extensions/ConfigurationExtensions.cs
@@ -82,6 +82,13 @@ public static MetricsOptions RegisterMetricsOptions(this WebApplicationBuilder b
return options;
}
+ public static GeoOptions RegisterGeoOptions(this WebApplicationBuilder builder)
+ {
+ var options = builder.Configuration.GetSection(GeoOptions.SectionName).Get() ?? new GeoOptions();
+ builder.Services.AddSingleton(options);
+ return options;
+ }
+
public static AccountOptions RegisterAccountOptions(this WebApplicationBuilder builder)
{
var options = builder.Configuration.GetSection("OpenShock:Account").Get()
diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs
index 166f46d0..19a06e2c 100644
--- a/Common/OpenShockControllerBase.cs
+++ b/Common/OpenShockControllerBase.cs
@@ -3,6 +3,7 @@
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Options;
+using OpenShock.Common.Services.Geo;
using OpenShock.Common.Services.Session;
using OpenShock.Common.Utils;
@@ -43,11 +44,14 @@ protected OkObjectResult LegacyEmptyOk(string message = "")
protected async Task CreateSession(Guid accountId, string domain)
{
var sessionService = HttpContext.RequestServices.GetRequiredService();
+ var enrichmentService = HttpContext.RequestServices.GetRequiredService();
var remoteIp = HttpContext.GetRemoteIP();
var userAgent = HttpContext.GetUserAgent();
+ var enrichment = enrichmentService.Enrich(remoteIp);
+
+ var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString(), actorId: accountId, enrichment: enrichment);
- var session = await sessionService.CreateSessionAsync(accountId, userAgent, remoteIp.ToString(), actorId: accountId);
HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions
{
diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs
index 43ad1dcd..5cce68de 100644
--- a/Common/OpenShockServiceHelper.cs
+++ b/Common/OpenShockServiceHelper.cs
@@ -20,6 +20,7 @@
using OpenShock.Common.Services.BatchUpdate;
using OpenShock.Common.Services.Configuration;
using OpenShock.Common.Services.RedisPubSub;
+using OpenShock.Common.Services.Geo;
using OpenShock.Common.Services.Session;
using OpenShock.Common.Services.Webhook;
using OpenTelemetry.Metrics;
@@ -237,6 +238,12 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se
services.AddScoped();
services.AddScoped();
services.AddScoped();
+
+ // Ensure GeoOptions is always resolvable so IpEnrichmentService can activate even in hosts
+ // (Cron, LiveControlGateway, SeedE2E) that don't call RegisterGeoOptions(). TryAdd leaves the
+ // API's config-bound instance untouched; other hosts get a disabled default (no DB paths).
+ services.TryAddSingleton(new GeoOptions());
+ services.AddSingleton();
services.AddHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
diff --git a/Common/Options/GeoOptions.cs b/Common/Options/GeoOptions.cs
new file mode 100644
index 00000000..95b7c642
--- /dev/null
+++ b/Common/Options/GeoOptions.cs
@@ -0,0 +1,16 @@
+namespace OpenShock.Common.Options;
+
+public sealed class GeoOptions
+{
+ public const string SectionName = "OpenShock:Geo";
+
+ ///
+ /// Path to the MaxMind GeoLite2-ASN.mmdb file.
+ ///
+ public string? AsnDbPath { get; init; }
+
+ ///
+ /// Path to the MaxMind GeoLite2-City.mmdb file.
+ ///
+ public string? CityDbPath { get; init; }
+}
diff --git a/Common/Redis/LoginSessions.cs b/Common/Redis/LoginSessions.cs
index 034e6e90..8ac1b6e1 100644
--- a/Common/Redis/LoginSessions.cs
+++ b/Common/Redis/LoginSessions.cs
@@ -20,4 +20,8 @@ public sealed class LoginSession
public DateTimeOffset? Expires { get; set; }
[JsonConverter(typeof(UnixMillisecondsDateTimeOffsetConverter))]
public DateTimeOffset? LastUsed { get; set; }
+ public string? AsnOrg { get; set; }
+ public bool? IsVpn { get; set; }
+ public string? CountryCode { get; set; }
+ public string? City { get; set; }
}
\ No newline at end of file
diff --git a/Common/Services/Geo/IIpEnrichmentService.cs b/Common/Services/Geo/IIpEnrichmentService.cs
new file mode 100644
index 00000000..31bea8e1
--- /dev/null
+++ b/Common/Services/Geo/IIpEnrichmentService.cs
@@ -0,0 +1,11 @@
+using System.Net;
+
+namespace OpenShock.Common.Services.Geo;
+
+public interface IIpEnrichmentService
+{
+ ///
+ /// Returns null when neither GeoLite2 database is configured or available.
+ ///
+ IpEnrichmentData? Enrich(IPAddress ip);
+}
diff --git a/Common/Services/Geo/IpEnrichmentData.cs b/Common/Services/Geo/IpEnrichmentData.cs
new file mode 100644
index 00000000..2af10145
--- /dev/null
+++ b/Common/Services/Geo/IpEnrichmentData.cs
@@ -0,0 +1,8 @@
+namespace OpenShock.Common.Services.Geo;
+
+public sealed record IpEnrichmentData(
+ string? AsnOrg,
+ bool? IsVpn,
+ string? CountryCode,
+ string? City
+);
diff --git a/Common/Services/Geo/IpEnrichmentService.cs b/Common/Services/Geo/IpEnrichmentService.cs
new file mode 100644
index 00000000..79e5a258
--- /dev/null
+++ b/Common/Services/Geo/IpEnrichmentService.cs
@@ -0,0 +1,115 @@
+using System.Net;
+using MaxMind.GeoIP2;
+using Microsoft.Extensions.Logging;
+using OpenShock.Common.Options;
+
+namespace OpenShock.Common.Services.Geo;
+
+public sealed class IpEnrichmentService : IIpEnrichmentService, IDisposable
+{
+ private static readonly string[] VpnKeywords =
+ [
+ "mullvad", "nordvpn", "expressvpn", "protonvpn", "ipvanish", "surfshark",
+ "privateinternetaccess", "pia", "hidemyass", "purevpn", "cyberghost",
+ "windscribe", "tunnelbear", "hotspot shield", "vyprvpn", "airvpn",
+ "perfect privacy", "ivpn", "ovpn",
+ // Datacenter / hosting ASNs that VPN exit nodes overwhelmingly use
+ "digitalocean", "linode", "akamai", "hetzner", "vultr", "ovh",
+ "amazon", "google", "microsoft", "choopa", "m247", "datacamp",
+ "frantech", "quadranet", "leaseweb", "serverius", "hostwinds",
+ "psychz", "tzulo", "nexeon", "misaka",
+ ];
+
+ private readonly DatabaseReader? _asnReader;
+ private readonly DatabaseReader? _cityReader;
+ private readonly ILogger _logger;
+
+ public IpEnrichmentService(GeoOptions options, ILogger logger)
+ {
+ _logger = logger;
+
+ _asnReader = TryOpen(options.AsnDbPath, "ASN");
+ _cityReader = TryOpen(options.CityDbPath, "City");
+ }
+
+ private DatabaseReader? TryOpen(string? path, string dbName)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ _logger.LogInformation("GeoLite2 {DbName} database path not configured, skipping", dbName);
+ return null;
+ }
+
+ if (!File.Exists(path))
+ {
+ _logger.LogWarning("GeoLite2 {DbName} database not found at {Path}", dbName, path);
+ return null;
+ }
+
+ try
+ {
+ return new DatabaseReader(path);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to open GeoLite2 {DbName} database at {Path}", dbName, path);
+ return null;
+ }
+ }
+
+ public IpEnrichmentData? Enrich(IPAddress ip)
+ {
+ if (_asnReader is null && _cityReader is null) return null;
+
+ string? asnOrg = null;
+ // Null means "unknown" (no ASN DB, lookup miss, or failure); only a resolved ASN org yields a verdict.
+ bool? isVpn = null;
+
+ if (_asnReader is not null)
+ {
+ try
+ {
+ if (_asnReader.TryAsn(ip, out var asn) && asn is not null)
+ {
+ asnOrg = asn.AutonomousSystemOrganization;
+ if (asnOrg is not null)
+ {
+ var lower = asnOrg.ToLowerInvariant();
+ isVpn = Array.Exists(VpnKeywords, k => lower.Contains(k));
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "ASN lookup failed for {Ip}", ip);
+ }
+ }
+
+ string? countryCode = null;
+ string? city = null;
+
+ if (_cityReader is not null)
+ {
+ try
+ {
+ if (_cityReader.TryCity(ip, out var cityResponse) && cityResponse is not null)
+ {
+ countryCode = cityResponse.Country.IsoCode;
+ city = cityResponse.City.Name;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "City lookup failed for {Ip}", ip);
+ }
+ }
+
+ return new IpEnrichmentData(asnOrg, isVpn, countryCode, city);
+ }
+
+ public void Dispose()
+ {
+ _asnReader?.Dispose();
+ _cityReader?.Dispose();
+ }
+}
diff --git a/Common/Services/Session/ISessionService.cs b/Common/Services/Session/ISessionService.cs
index 474a42a4..2022fa2d 100644
--- a/Common/Services/Session/ISessionService.cs
+++ b/Common/Services/Session/ISessionService.cs
@@ -1,10 +1,11 @@
using OpenShock.Common.Redis;
+using OpenShock.Common.Services.Geo;
namespace OpenShock.Common.Services.Session;
public interface ISessionService
{
- public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId);
+ public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null);
public IAsyncEnumerable ListSessionsByUserIdAsync(Guid userId);
diff --git a/Common/Services/Session/SessionService.cs b/Common/Services/Session/SessionService.cs
index f5d7d3fb..500435ed 100644
--- a/Common/Services/Session/SessionService.cs
+++ b/Common/Services/Session/SessionService.cs
@@ -4,6 +4,7 @@
using OpenShock.Common.OpenShockDb;
using OpenShock.Common.Redis;
using OpenShock.Common.Services.Audit;
+using OpenShock.Common.Services.Geo;
using OpenShock.Common.Utils;
using Redis.OM;
using Redis.OM.Contracts;
@@ -32,7 +33,7 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider, IAuditSe
_auditService = auditService;
}
- public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId)
+ public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, Guid? actorId, IpEnrichmentData? enrichment = null)
{
Guid id = Guid.CreateVersion7();
string token = CryptoUtils.RandomString(AuthConstants.GeneratedTokenLength);
@@ -46,6 +47,10 @@ await _loginSessions.InsertAsync(new LoginSession
PublicId = id,
Created = DateTime.UtcNow,
Expires = DateTime.UtcNow.Add(Duration.LoginSessionLifetime),
+ AsnOrg = enrichment?.AsnOrg,
+ IsVpn = enrichment?.IsVpn,
+ CountryCode = enrichment?.CountryCode,
+ City = enrichment?.City,
}, Duration.LoginSessionLifetime);
await _auditService.LogAsync(
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 161abf87..2f54eb8b 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -12,6 +12,7 @@
+