From 3647333a2feaf4885078a9ad1cc7d8c70375b3b9 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Fri, 12 Jun 2026 15:33:46 +0200 Subject: [PATCH 1/3] Add GeoIP support --- API/Models/Response/LoginSessionResponse.cs | 10 +- API/Program.cs | 1 + Common/Common.csproj | 1 + Common/Extensions/ConfigurationExtensions.cs | 7 ++ Common/OpenShockControllerBase.cs | 9 +- Common/OpenShockServiceHelper.cs | 2 + Common/Options/GeoOptions.cs | 16 +++ Common/Redis/LoginSessions.cs | 4 + Common/Services/Geo/IIpEnrichmentService.cs | 11 ++ Common/Services/Geo/IpEnrichmentData.cs | 8 ++ Common/Services/Geo/IpEnrichmentService.cs | 114 +++++++++++++++++++ Common/Services/Session/ISessionService.cs | 3 +- Common/Services/Session/SessionService.cs | 7 +- Directory.Packages.props | 1 + 14 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 Common/Options/GeoOptions.cs create mode 100644 Common/Services/Geo/IIpEnrichmentService.cs create mode 100644 Common/Services/Geo/IpEnrichmentData.cs create mode 100644 Common/Services/Geo/IpEnrichmentService.cs 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 5dcf07fa..08907479 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -26,6 +26,7 @@ var databaseOptions = builder.RegisterDatabaseOptions(); builder.RegisterMetricsOptions(); builder.RegisterFrontendOptions(); +builder.RegisterGeoOptions(); builder.Services .AddOpenShockMemDB(redisOptions) diff --git a/Common/Common.csproj b/Common/Common.csproj index a1eb241f..e2253b45 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Common/Extensions/ConfigurationExtensions.cs b/Common/Extensions/ConfigurationExtensions.cs index cf5b910a..765b685f 100644 --- a/Common/Extensions/ConfigurationExtensions.cs +++ b/Common/Extensions/ConfigurationExtensions.cs @@ -80,6 +80,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 FrontendOptions RegisterFrontendOptions(this WebApplicationBuilder builder) { var section = builder.Configuration.GetRequiredSection("OpenShock:Frontend"); diff --git a/Common/OpenShockControllerBase.cs b/Common/OpenShockControllerBase.cs index 4e11c2cb..601d5c58 100644 --- a/Common/OpenShockControllerBase.cs +++ b/Common/OpenShockControllerBase.cs @@ -3,6 +3,7 @@ using OpenShock.Common.Models; using OpenShock.Common.Options; using OpenShock.Common.Problems; +using OpenShock.Common.Services.Geo; using OpenShock.Common.Services.Session; using OpenShock.Common.Utils; @@ -42,8 +43,12 @@ protected OkObjectResult LegacyEmptyOk(string message = "") protected async Task CreateSession(Guid accountId, string domain) { var sessionService = HttpContext.RequestServices.GetRequiredService(); - - var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), HttpContext.GetRemoteIP().ToString()); + var enrichmentService = HttpContext.RequestServices.GetRequiredService(); + + var remoteIp = HttpContext.GetRemoteIP(); + var enrichment = enrichmentService.Enrich(remoteIp); + + var session = await sessionService.CreateSessionAsync(accountId, HttpContext.GetUserAgent(), remoteIp.ToString(), enrichment); HttpContext.Response.Cookies.Append(AuthConstants.UserSessionCookieName, session.Token, new CookieOptions { diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index 232a2202..d8e4db22 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -19,6 +19,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; @@ -199,6 +200,7 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se services.AddScoped(); services.AddScoped(); + 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..fde74a11 --- /dev/null +++ b/Common/Options/GeoOptions.cs @@ -0,0 +1,16 @@ +namespace OpenShock.Common.Options; + +public sealed class GeoOptions +{ + public const string SectionName = "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 34b4f8a3..b0aad4d2 100644 --- a/Common/Redis/LoginSessions.cs +++ b/Common/Redis/LoginSessions.cs @@ -18,4 +18,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..223b389f --- /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..990719c2 --- /dev/null +++ b/Common/Services/Geo/IpEnrichmentService.cs @@ -0,0 +1,114 @@ +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; + bool isVpn = false; + + 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 6538356e..dd7dfe29 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); + public Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, IpEnrichmentData? enrichment = null); public IAsyncEnumerable ListSessionsByUserIdAsync(Guid userId); diff --git a/Common/Services/Session/SessionService.cs b/Common/Services/Session/SessionService.cs index f506640f..a4edb221 100644 --- a/Common/Services/Session/SessionService.cs +++ b/Common/Services/Session/SessionService.cs @@ -1,6 +1,7 @@ using Microsoft.EntityFrameworkCore; using OpenShock.Common.Constants; using OpenShock.Common.Redis; +using OpenShock.Common.Services.Geo; using OpenShock.Common.Utils; using Redis.OM; using Redis.OM.Contracts; @@ -24,7 +25,7 @@ public SessionService(IRedisConnectionProvider redisConnectionProvider) _loginSessions = redisConnectionProvider.RedisCollection(false); } - public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress) + public async Task CreateSessionAsync(Guid userId, string userAgent, string ipAddress, IpEnrichmentData? enrichment = null) { Guid id = Guid.CreateVersion7(); string token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); @@ -38,6 +39,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); return new CreateSessionResult(id, token); diff --git a/Directory.Packages.props b/Directory.Packages.props index b0317f27..a9a0c35a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + From b24627dbceea1a3c4637fc5bf5b572fc01e5a171 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 1 Jul 2026 16:33:25 +0200 Subject: [PATCH 2/3] fix(geo): address PR review comments - Register a default GeoOptions via TryAddSingleton in AddOpenShockServices so hosts that don't call RegisterGeoOptions() (Cron, LiveControlGateway, SeedE2E) can still activate IIpEnrichmentService instead of failing DI at startup. - Make IpEnrichmentData.IsVpn nullable so an unavailable/failed ASN lookup stays 'unknown' (null) rather than being recorded as 'not VPN' (false). - Rename GeoOptions section to 'OpenShock:Geo' to match the repo's config convention. --- Common/OpenShockServiceHelper.cs | 4 ++++ Common/Options/GeoOptions.cs | 2 +- Common/Services/Geo/IpEnrichmentData.cs | 2 +- Common/Services/Geo/IpEnrichmentService.cs | 3 ++- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Common/OpenShockServiceHelper.cs b/Common/OpenShockServiceHelper.cs index d8e4db22..62fb6f2a 100644 --- a/Common/OpenShockServiceHelper.cs +++ b/Common/OpenShockServiceHelper.cs @@ -200,6 +200,10 @@ public static IServiceCollection AddOpenShockServices(this IServiceCollection se 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 => { diff --git a/Common/Options/GeoOptions.cs b/Common/Options/GeoOptions.cs index fde74a11..95b7c642 100644 --- a/Common/Options/GeoOptions.cs +++ b/Common/Options/GeoOptions.cs @@ -2,7 +2,7 @@ namespace OpenShock.Common.Options; public sealed class GeoOptions { - public const string SectionName = "Geo"; + public const string SectionName = "OpenShock:Geo"; /// /// Path to the MaxMind GeoLite2-ASN.mmdb file. diff --git a/Common/Services/Geo/IpEnrichmentData.cs b/Common/Services/Geo/IpEnrichmentData.cs index 223b389f..2af10145 100644 --- a/Common/Services/Geo/IpEnrichmentData.cs +++ b/Common/Services/Geo/IpEnrichmentData.cs @@ -2,7 +2,7 @@ namespace OpenShock.Common.Services.Geo; public sealed record IpEnrichmentData( string? AsnOrg, - bool IsVpn, + bool? IsVpn, string? CountryCode, string? City ); diff --git a/Common/Services/Geo/IpEnrichmentService.cs b/Common/Services/Geo/IpEnrichmentService.cs index 990719c2..79e5a258 100644 --- a/Common/Services/Geo/IpEnrichmentService.cs +++ b/Common/Services/Geo/IpEnrichmentService.cs @@ -62,7 +62,8 @@ public IpEnrichmentService(GeoOptions options, ILogger logg if (_asnReader is null && _cityReader is null) return null; string? asnOrg = null; - bool isVpn = false; + // 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) { From fbfcf2e430932d30a231499c2dfc887c9951b3f1 Mon Sep 17 00:00:00 2001 From: HeavenVR Date: Wed, 19 Aug 2026 17:19:42 +0200 Subject: [PATCH 3/3] fix: Report only real VPN providers in IsVpn The keyword list carried datacenter ASNs (amazon, google, akamai, ovh), so corporate and cloud egress was flagged as VPN, and substring matching let "pia" match "Olympia". Matching is now whole-token over consumer VPN vendors. --- API/Models/Response/LoginSessionResponse.cs | 2 +- .../Services/IpEnrichmentServiceTests.cs | 50 +++++++++++++++++++ Common/Services/Geo/IpEnrichmentService.cs | 48 +++++++++++++----- 3 files changed, 87 insertions(+), 13 deletions(-) create mode 100644 Common.Tests/Services/IpEnrichmentServiceTests.cs diff --git a/API/Models/Response/LoginSessionResponse.cs b/API/Models/Response/LoginSessionResponse.cs index 98bb57a3..8499dd85 100644 --- a/API/Models/Response/LoginSessionResponse.cs +++ b/API/Models/Response/LoginSessionResponse.cs @@ -31,4 +31,4 @@ public static LoginSessionResponse MapFrom(LoginSession session) public bool? IsVpn { get; init; } public string? CountryCode { get; init; } public string? City { get; init; } -} \ No newline at end of file +} diff --git a/Common.Tests/Services/IpEnrichmentServiceTests.cs b/Common.Tests/Services/IpEnrichmentServiceTests.cs new file mode 100644 index 00000000..ce4957c9 --- /dev/null +++ b/Common.Tests/Services/IpEnrichmentServiceTests.cs @@ -0,0 +1,50 @@ +using OpenShock.Common.Services.Geo; + +namespace OpenShock.Common.Tests.Services; + +public class IpEnrichmentServiceTests +{ + [Test] + [Arguments("NordVPN")] + [Arguments("Mullvad VPN AB")] + [Arguments("Private Internet Access, Inc.")] + [Arguments("privateinternetaccess.com")] + [Arguments("Hotspot Shield")] + [Arguments("PIA")] + public async Task MatchesVpnProvider_KnownVpnOrgs_ReturnsTrue(string asnOrg) + { + await Assert.That(IpEnrichmentService.MatchesVpnProvider(asnOrg)).IsTrue(); + } + + // The datacenter and hosting ASNs that used to live in the keyword list. They carry ordinary + // traffic and must not be reported as VPNs. + [Test] + [Arguments("Amazon.com, Inc.")] + [Arguments("Google LLC")] + [Arguments("Microsoft Corporation")] + [Arguments("Akamai Technologies, Inc.")] + [Arguments("OVH SAS")] + [Arguments("Hetzner Online GmbH")] + [Arguments("DigitalOcean, LLC")] + public async Task MatchesVpnProvider_HostingOrgs_ReturnsFalse(string asnOrg) + { + await Assert.That(IpEnrichmentService.MatchesVpnProvider(asnOrg)).IsFalse(); + } + + // "pia" is a real vendor abbreviation and also a substring of ordinary words. Whole-token + // matching is what keeps the second case from being reported as a VPN. + [Test] + [Arguments("Olympia Networks")] + [Arguments("Compia Telecom")] + [Arguments("Utopia Broadband")] + public async Task MatchesVpnProvider_SubstringLookalikes_ReturnsFalse(string asnOrg) + { + await Assert.That(IpEnrichmentService.MatchesVpnProvider(asnOrg)).IsFalse(); + } + + [Test] + public async Task MatchesVpnProvider_EmptyOrg_ReturnsFalse() + { + await Assert.That(IpEnrichmentService.MatchesVpnProvider(string.Empty)).IsFalse(); + } +} diff --git a/Common/Services/Geo/IpEnrichmentService.cs b/Common/Services/Geo/IpEnrichmentService.cs index 79e5a258..a604ea68 100644 --- a/Common/Services/Geo/IpEnrichmentService.cs +++ b/Common/Services/Geo/IpEnrichmentService.cs @@ -1,4 +1,4 @@ -using System.Net; +using System.Net; using MaxMind.GeoIP2; using Microsoft.Extensions.Logging; using OpenShock.Common.Options; @@ -7,17 +7,21 @@ namespace OpenShock.Common.Services.Geo; public sealed class IpEnrichmentService : IIpEnrichmentService, IDisposable { - private static readonly string[] VpnKeywords = + // Consumer VPN providers only. Datacenter and hosting ASNs are deliberately NOT listed here: + // AWS, Google, Microsoft, Akamai, OVH and friends carry an enormous amount of ordinary traffic + // (corporate egress, mobile carrier NAT, CDN-fronted clients), so treating them as VPN evidence + // flags a large share of legitimate users. That signal is worth surfacing one day, but as its own + // "hosting provider" field rather than folded into a flag the UI presents as "VPN". + // + // Matched against whole tokens, not raw substrings, so "pia" cannot match "Olympia". Multi-word + // vendors are listed in both spaced and concatenated form because ASN org names use both. + private static readonly string[] VpnProviderKeywords = [ "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", + "privateinternetaccess", "private internet access", "pia", + "hidemyass", "hide my ass", "purevpn", "cyberghost", + "windscribe", "tunnelbear", "hotspot shield", "hotspotshield", + "vyprvpn", "airvpn", "perfect privacy", "perfectprivacy", "ivpn", "ovpn", ]; private readonly DatabaseReader? _asnReader; @@ -32,6 +36,27 @@ public IpEnrichmentService(GeoOptions options, ILogger logg _cityReader = TryOpen(options.CityDbPath, "City"); } + /// + /// Whole-token match of an ASN organisation name against . + /// Punctuation becomes whitespace so "Amazon.com, Inc." tokenises the way a reader expects, and + /// the padded haystack lets a single Contains express a word-boundary match for phrases too. + /// + public static bool MatchesVpnProvider(string asnOrg) + { + var normalized = string.Create(asnOrg.Length, asnOrg, static (span, source) => + { + for (var i = 0; i < source.Length; i++) + { + var c = char.ToLowerInvariant(source[i]); + span[i] = char.IsLetterOrDigit(c) ? c : ' '; + } + }); + + var haystack = $" {string.Join(' ', normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries))} "; + + return Array.Exists(VpnProviderKeywords, k => haystack.Contains($" {k} ", StringComparison.Ordinal)); + } + private DatabaseReader? TryOpen(string? path, string dbName) { if (string.IsNullOrWhiteSpace(path)) @@ -74,8 +99,7 @@ public IpEnrichmentService(GeoOptions options, ILogger logg asnOrg = asn.AutonomousSystemOrganization; if (asnOrg is not null) { - var lower = asnOrg.ToLowerInvariant(); - isVpn = Array.Exists(VpnKeywords, k => lower.Contains(k)); + isVpn = MatchesVpnProvider(asnOrg); } } }