diff --git a/src/Empostor.Api.Innersloth.Generator/packages.lock.json b/src/Empostor.Api.Innersloth.Generator/packages.lock.json index fb68fad..5850ca3 100644 --- a/src/Empostor.Api.Innersloth.Generator/packages.lock.json +++ b/src/Empostor.Api.Innersloth.Generator/packages.lock.json @@ -150,6 +150,7 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.3" } } - } + }, + ".NETStandard,Version=v2.0/win-x64": {} } } \ No newline at end of file diff --git a/src/Empostor.Api/packages.lock.json b/src/Empostor.Api/packages.lock.json index 9d991b4..049e555 100644 --- a/src/Empostor.Api/packages.lock.json +++ b/src/Empostor.Api/packages.lock.json @@ -34,6 +34,12 @@ "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" } }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.28, )", + "resolved": "8.0.28", + "contentHash": "XMqgVjlLxLqWmEh3c49haXLQwsMNtvo6YscUaqfvEGfg1iA8hnYgkUVq3i9Zu9gKeNKMWiiZKVwZExc/qyEAsQ==" + }, "StyleCop.Analyzers": { "type": "Direct", "requested": "[1.2.0-beta.556, )", @@ -139,6 +145,7 @@ "NextFast.Hazel.Abstractions": { "type": "Project" } - } + }, + "net8.0/win-x64": {} } } \ No newline at end of file diff --git a/src/Empostor.Server/Http/MarketplaceController.cs b/src/Empostor.Server/Http/MarketplaceController.cs index ddbfa0a..d289bbb 100644 --- a/src/Empostor.Server/Http/MarketplaceController.cs +++ b/src/Empostor.Server/Http/MarketplaceController.cs @@ -37,12 +37,12 @@ namespace Empostor.Server.Http _http = http; _config = config.Value; _pluginLoaderService = pluginLoaderService; - _passwordHash = AdminAuthHelper.ComputeHash(_config.Password); + _passwordHash = AdminController.ComputeHash(_config.Password); } private bool IsAuthenticated() => Request.Cookies.TryGetValue("empostor_admin", out var v) - && AdminAuthHelper.ConstantTimeEquals(v, _passwordHash); + && AdminController.ConstantTimeEquals(v, _passwordHash); [HttpGet("/api/admin/marketplace/plugins")] public async Task ListPlugins() diff --git a/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs b/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs index d568c3f..6b19435 100644 --- a/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs +++ b/src/Empostor.Server/Net/Inner/Objects/InnerPlayerControl.cs @@ -1146,6 +1146,10 @@ namespace Empostor.Server.Net.Inner.Objects private async ValueTask HandleSendChat(ClientPlayer sender, string message) { + var clientId = sender.Client.Id; + var playerName = sender.Client.Name; + var slot = sender.Character?.PlayerId ?? 0; + if (message.StartsWith("#")) { var ctx = new CommandContext @@ -1162,7 +1166,10 @@ namespace Empostor.Server.Net.Inner.Objects }; var handled = await _commandService.TryHandleAsync(ctx); if (handled) + { + _logger.LogInformation("[{ClientId}][{Name}][{Slot}][{Name}] => [255]Command:\n [{Message}]", clientId, playerName, slot, message); return false; + } } var @event = new PlayerChatEvent(Game, sender, this, message); @@ -1174,10 +1181,12 @@ namespace Empostor.Server.Net.Inner.Objects await _eventManager.CallAsync(@event); if (@event.IsCancelled) { + _logger.LogInformation("[{ClientId}][{Name}][{Slot}][{Name}] => [0]Canceled:\n [{Message}]", clientId, playerName, slot, message); return false; } else if (@event.SendToAllPlayers == false) { + _logger.LogInformation("[{ClientId}][{Name}][{Slot}][{Name}] => [255]Command:\n [{Message}]", clientId, playerName, slot, message); if (Game.Host != null) { await SendChatToPlayerAsync(message, Game.Host.Character); @@ -1187,6 +1196,7 @@ namespace Empostor.Server.Net.Inner.Objects } else { + _logger.LogInformation("[{ClientId}][{Name}][{Slot}][{Name}] => [-1]Public:\n [{Message}]", clientId, playerName, slot, message); return true; } } diff --git a/src/Empostor.Server/Net/Manager/ClientManager.cs b/src/Empostor.Server/Net/Manager/ClientManager.cs index 6b46ec6..7002f87 100644 --- a/src/Empostor.Server/Net/Manager/ClientManager.cs +++ b/src/Empostor.Server/Net/Manager/ClientManager.cs @@ -14,6 +14,7 @@ using Empostor.Server.Events.Client; using Empostor.Server.Net.Factories; using Empostor.Api.Service; using Empostor.Server.Service.Auth; +using Empostor.Server.Utils; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Next.Hazel; @@ -33,6 +34,7 @@ namespace Empostor.Server.Net.Manager private readonly IHttpClientFactory _httpClientFactory; private readonly AuthApiConfig _authApiConfig; private readonly PortPoolService _portPool; + private readonly IpGeolocationService _ipGeo; private int _idLast; public ClientManager( @@ -45,7 +47,8 @@ namespace Empostor.Server.Net.Manager PlayerConnectStore playerConnectStore, IHttpClientFactory httpClientFactory, IOptions authApiConfig, - PortPoolService portPool) + PortPoolService portPool, + IpGeolocationService ipGeo) { _logger = logger; _eventManager = eventManager; @@ -58,6 +61,7 @@ namespace Empostor.Server.Net.Manager _httpClientFactory = httpClientFactory; _authApiConfig = authApiConfig.Value; _portPool = portPool; + _ipGeo = ipGeo; if (_compatibilityConfig.AllowFutureGameVersions) { @@ -138,6 +142,7 @@ namespace Empostor.Server.Net.Manager string? friendCode = null; UserAuthInfo? authInfo = null; var clientIp = connection.EndPoint?.Address; + var location = await _ipGeo.GetLocationAsync(clientIp); // Primary: match by delta port (nonce) if (deltaPort > 0) @@ -172,8 +177,8 @@ namespace Empostor.Server.Net.Manager } _logger.LogInformation( - "Client [{Id}]{Name} from port {Port} is authorized as {FriendCode} {HashPuid}", - id, name, deltaPort, friendCode ?? "unknown", HashPuid(authInfo.ProductUserId)); + "Client [{Id}]{Name} from port {Port} {Location}is authorized as {FriendCode} {HashPuid}", + id, name, deltaPort, location, friendCode ?? "unknown", HashPuid(authInfo.ProductUserId)); } else { @@ -191,14 +196,14 @@ namespace Empostor.Server.Net.Manager { friendCode = authInfo.FriendCode; _logger.LogInformation( - "Client [{Id}]{Name} from {Ip} is authorized as {FriendCode} {HashPuid}", - id, name, NormalizeIp(clientIp), friendCode ?? "unknown", HashPuid(authInfo.ProductUserId)); + "Client [{Id}]{Name} from {Ip} {Location}is authorized as {FriendCode} {HashPuid}", + id, name, NormalizeIp(clientIp), location, friendCode ?? "unknown", HashPuid(authInfo.ProductUserId)); } else { _logger.LogWarning( - "Client [{Id}]{Name}: no auth info found (port={Port}, ip={Ip})", - id, name, deltaPort, NormalizeIp(clientIp)); + "Client [{Id}]{Name}: no auth info found (port={Port}, ip={Ip} {Location})", + id, name, deltaPort, NormalizeIp(clientIp), location); } } diff --git a/src/Empostor.Server/Net/State/Game.Incoming.cs b/src/Empostor.Server/Net/State/Game.Incoming.cs index 0573923..924483b 100644 --- a/src/Empostor.Server/Net/State/Game.Incoming.cs +++ b/src/Empostor.Server/Net/State/Game.Incoming.cs @@ -126,9 +126,10 @@ namespace Empostor.Server.Net.State var version = client.GameVersion.ToString(); var hashPuid = client.ProductUserId?.Length >= 9 ? client.ProductUserId[..9] : client.ProductUserId ?? "0"; var deltaPort = client.DeltaPort; + var location = await _ipGeo.GetLocationAsync(clientIp); - _logger.LogInformation("{Code} - Player {Name} ({HashPuid}) ({Id}) is joining from ({Ip}:{RemotePort}) with v{Version}, Authority:{Authority}, port: {DeltaPort}", - Code, sender.Client.Name, hashPuid, sender.Client.Id, ipStr, remotePort, version, authority, deltaPort); + _logger.LogInformation("{Code} - Player {Name} ({HashPuid}) ({Id}) is joining from ({Ip} {Location}:{RemotePort}) with v{Version}, Authority:{Authority}, port: {DeltaPort}", + Code, sender.Client.Name, hashPuid, sender.Client.Id, ipStr, location, remotePort, version, authority, deltaPort); if (isNew) { diff --git a/src/Empostor.Server/Net/State/Game.cs b/src/Empostor.Server/Net/State/Game.cs index 9c3f008..142f591 100644 --- a/src/Empostor.Server/Net/State/Game.cs +++ b/src/Empostor.Server/Net/State/Game.cs @@ -16,6 +16,7 @@ using Empostor.Api.Net.Manager; using Empostor.Api.Net.Messages.S2C; using Empostor.Server.Events; using Empostor.Server.Net.Manager; +using Empostor.Server.Utils; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -33,6 +34,7 @@ namespace Empostor.Server.Net.State private readonly ICompatibilityManager _compatibilityManager; private readonly CompatibilityConfig _compatibilityConfig; private readonly TimeoutConfig _timeoutConfig; + private readonly IpGeolocationService _ipGeo; public Game( ILogger logger, @@ -46,7 +48,8 @@ namespace Empostor.Server.Net.State IEventManager eventManager, ICompatibilityManager compatibilityManager, IOptions compatibilityConfig, - IOptions timeoutConfig) + IOptions timeoutConfig, + IpGeolocationService ipGeo) { _logger = logger; _serviceProvider = serviceProvider; @@ -65,6 +68,7 @@ namespace Empostor.Server.Net.State _compatibilityManager = compatibilityManager; _compatibilityConfig = compatibilityConfig.Value; _timeoutConfig = timeoutConfig.Value; + _ipGeo = ipGeo; Items = new ConcurrentDictionary(); } diff --git a/src/Empostor.Server/Program.cs b/src/Empostor.Server/Program.cs index 959aefe..21b63fa 100644 --- a/src/Empostor.Server/Program.cs +++ b/src/Empostor.Server/Program.cs @@ -54,7 +54,8 @@ namespace Empostor.Server private static int Main(string[] args) { Log.Logger = new LoggerConfiguration() - .WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Source} - {Message:lj}{NewLine}{Exception}") + .Enrich.With() .CreateBootstrapLogger(); try { @@ -156,7 +157,12 @@ namespace Empostor.Server { client.Timeout = TimeSpan.FromSeconds(15); }); + services.AddHttpClient("ipgeo", client => + { + client.Timeout = TimeSpan.FromSeconds(5); + }); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(p => p.GetRequiredService()); @@ -248,12 +254,13 @@ namespace Empostor.Server #endif .MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning) .Enrich.FromLogContext() - .WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + .Enrich.With() + .WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Source} - {Message:lj}{NewLine}{Exception}") .WriteTo.File( "Log/empostor-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 31, - outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}") + outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Source} - {Message:lj}{NewLine}{Exception}") .ReadFrom.Configuration(context.Configuration, new ConfigurationReaderOptions(ConfigurationAssemblySource.AlwaysScanDllFiles)); AssemblyLoadContext.Default.Resolving -= TryLoad; diff --git a/src/Empostor.Server/Utils/IpGeolocationService.cs b/src/Empostor.Server/Utils/IpGeolocationService.cs new file mode 100644 index 0000000..d569a7e --- /dev/null +++ b/src/Empostor.Server/Utils/IpGeolocationService.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Empostor.Server.Utils +{ + internal class IpGeolocationService + { + private const string ApiUrl = "http://ip-api.com/json/{0}?lang=zh-CN&fields=status,country,regionName,city"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(24); + + private readonly ConcurrentDictionary _cache = new(); + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public IpGeolocationService(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async ValueTask GetLocationAsync(IPAddress? ip) + { + if (ip == null || IsPrivateIp(ip)) + { + return string.Empty; + } + + // Check cache + if (_cache.TryGetValue(ip, out var cached) && cached.Expiry > DateTime.UtcNow) + { + return cached.Location; + } + + var location = await QueryApiAsync(ip); + _cache[ip] = (location, DateTime.UtcNow.Add(CacheTtl)); + return location; + } + + private async Task QueryApiAsync(IPAddress ip) + { + try + { + var normalizedIp = ip.IsIPv4MappedToIPv6 ? ip.MapToIPv4() : ip; + var url = string.Format(ApiUrl, normalizedIp); + + using var client = _httpClientFactory.CreateClient("ipgeo"); + using var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(3)); + var json = await client.GetStringAsync(url, cts.Token); + + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("status", out var status) && status.GetString() == "success") + { + var country = root.TryGetProperty("country", out var c) ? c.GetString() ?? string.Empty : string.Empty; + var region = root.TryGetProperty("regionName", out var r) ? r.GetString() ?? string.Empty : string.Empty; + var city = root.TryGetProperty("city", out var ct) ? ct.GetString() ?? string.Empty : string.Empty; + + if (string.IsNullOrEmpty(country) && string.IsNullOrEmpty(city)) + { + return string.Empty; + } + + var parts = country != city + ? (region != city ? $"{country} {region} {city}" : $"{country} {city}") + : country; + + return parts.Trim(); + } + + return string.Empty; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "IP geolocation lookup failed for {Ip}", ip); + return string.Empty; + } + } + + private static bool IsPrivateIp(IPAddress ip) + { + if (ip.IsIPv4MappedToIPv6) + { + ip = ip.MapToIPv4(); + } + + if (ip.AddressFamily != AddressFamily.InterNetwork) + { + return true; // Don't query non-IPv4 for now + } + + var bytes = ip.GetAddressBytes(); + if (bytes.Length != 4) + { + return true; + } + + // 127.0.0.0/8 + if (bytes[0] == 127) + { + return true; + } + + // 10.0.0.0/8 + if (bytes[0] == 10) + { + return true; + } + + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) + { + return true; + } + + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) + { + return true; + } + + return false; + } + } +} diff --git a/src/Empostor.Server/Utils/ShortSourceEnricher.cs b/src/Empostor.Server/Utils/ShortSourceEnricher.cs new file mode 100644 index 0000000..69f0770 --- /dev/null +++ b/src/Empostor.Server/Utils/ShortSourceEnricher.cs @@ -0,0 +1,23 @@ +using Serilog.Core; +using Serilog.Events; + +namespace Empostor.Server.Utils +{ + internal class ShortSourceEnricher : ILogEventEnricher + { + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + if (logEvent.Properties.TryGetValue("SourceContext", out var sourceContextValue) && + sourceContextValue is ScalarValue scalar && + scalar.Value is string fullName) + { + var shortName = fullName[(fullName.LastIndexOf('.') + 1)..]; + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("Source", shortName)); + } + else + { + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("Source", "Empostor")); + } + } + } +} diff --git a/src/Empostor.Server/packages.lock.json b/src/Empostor.Server/packages.lock.json index 9d36848..4588685 100644 --- a/src/Empostor.Server/packages.lock.json +++ b/src/Empostor.Server/packages.lock.json @@ -58,6 +58,12 @@ "resolved": "8.0.1", "contentHash": "X21b8D6z58EG34OMLH955750mFaEaGs5xLGe9Vyi3059Cgb3sk+p2k/JlQXaieEBRrk+SVlFzDei3Q6PdVzJOA==" }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.28, )", + "resolved": "8.0.28", + "contentHash": "XMqgVjlLxLqWmEh3c49haXLQwsMNtvo6YscUaqfvEGfg1iA8hnYgkUVq3i9Zu9gKeNKMWiiZKVwZExc/qyEAsQ==" + }, "Serilog.Extensions.Hosting": { "type": "Direct", "requested": "[8.0.0, )",