Add IPIntel API support. (#33339)

Co-authored-by: PJB3005 <pieterjan.briers+git@gmail.com>
This commit is contained in:
Myra
2025-01-12 20:41:26 +01:00
committed by GitHub
parent 57442fc336
commit 96d913b147
20 changed files with 5227 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
using System;
using System.Net;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Content.Server.Database.Migrations.Postgres
{
/// <inheritdoc />
public partial class IPIntel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ipintel_cache",
columns: table => new
{
ipintel_cache_id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
address = table.Column<IPAddress>(type: "inet", nullable: false),
time = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
score = table.Column<float>(type: "real", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ipintel_cache", x => x.ipintel_cache_id);
});
migrationBuilder.Sql("CREATE UNIQUE INDEX idx_ipintel_cache_address ON ipintel_cache(address)");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ipintel_cache");
}
}
}

View File

@@ -627,6 +627,34 @@ namespace Content.Server.Database.Migrations.Postgres
});
});
modelBuilder.Entity("Content.Server.Database.IPIntelCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("ipintel_cache_id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<IPAddress>("Address")
.IsRequired()
.HasColumnType("inet")
.HasColumnName("address");
b.Property<float>("Score")
.HasColumnType("real")
.HasColumnName("score");
b.Property<DateTime>("Time")
.HasColumnType("timestamp with time zone")
.HasColumnName("time");
b.HasKey("Id")
.HasName("PK_ipintel_cache");
b.ToTable("ipintel_cache", (string)null);
});
modelBuilder.Entity("Content.Server.Database.Job", b =>
{
b.Property<int>("Id")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Content.Server.Database.Migrations.Sqlite
{
/// <inheritdoc />
public partial class IPIntel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ipintel_cache",
columns: table => new
{
ipintel_cache_id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
address = table.Column<string>(type: "TEXT", nullable: false),
time = table.Column<DateTime>(type: "TEXT", nullable: false),
score = table.Column<float>(type: "REAL", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ipintel_cache", x => x.ipintel_cache_id);
});
migrationBuilder.CreateIndex(
name: "IX_ipintel_cache_address",
table: "ipintel_cache",
column: "address",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ipintel_cache");
}
}
}

View File

@@ -591,6 +591,35 @@ namespace Content.Server.Database.Migrations.Sqlite
b.ToTable("connection_log", (string)null);
});
modelBuilder.Entity("Content.Server.Database.IPIntelCache", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("ipintel_cache_id");
b.Property<string>("Address")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("address");
b.Property<float>("Score")
.HasColumnType("REAL")
.HasColumnName("score");
b.Property<DateTime>("Time")
.HasColumnType("TEXT")
.HasColumnName("time");
b.HasKey("Id")
.HasName("PK_ipintel_cache");
b.HasIndex("Address")
.IsUnique();
b.ToTable("ipintel_cache", (string)null);
});
modelBuilder.Entity("Content.Server.Database.Job", b =>
{
b.Property<int>("Id")

View File

@@ -45,6 +45,7 @@ namespace Content.Server.Database
public DbSet<AdminMessage> AdminMessages { get; set; } = null!;
public DbSet<RoleWhitelist> RoleWhitelists { get; set; } = null!;
public DbSet<BanTemplate> BanTemplate { get; set; } = null!;
public DbSet<IPIntelCache> IPIntelCache { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -968,6 +969,8 @@ namespace Content.Server.Database
* Reservation by commenting out the value is likely sufficient for this purpose, but may impact projects which depend on SS14 like SS14.Admin.
*/
BabyJail = 4,
/// Results from rejected connections with external API checking tools
IPChecks = 5,
}
public class ServerBanHit
@@ -1284,4 +1287,28 @@ namespace Content.Server.Database
return new ImmutableTypedHwid(hwid.Hwid.ToImmutableArray(), hwid.Type);
}
}
/// <summary>
/// Cache for the IPIntel system
/// </summary>
public class IPIntelCache
{
public int Id { get; set; }
/// <summary>
/// The IP address (duh). This is made unique manually for psql cause of ef core bug.
/// </summary>
public IPAddress Address { get; set; } = null!;
/// <summary>
/// Date this record was added. Used to check if our cache is out of date.
/// </summary>
public DateTime Time { get; set; }
/// <summary>
/// The score IPIntel returned
/// </summary>
public float Score { get; set; }
}
}

View File

@@ -81,6 +81,11 @@ namespace Content.Server.Database
modelBuilder.Entity<Profile>()
.Property(log => log.Markings)
.HasConversion(jsonByteArrayConverter);
// EF core can make this automatically unique on sqlite but not psql.
modelBuilder.Entity<IPIntelCache>()
.HasIndex(p => p.Address)
.IsUnique();
}
public override int CountAdminLogs()

View File

@@ -17,7 +17,7 @@ public sealed partial class ConnectionManager
{
private PlayerConnectionWhitelistPrototype[]? _whitelists;
public void PostInit()
private void InitializeWhitelist()
{
_cfg.OnValueChanged(CCVars.WhitelistPrototypeList, UpdateWhitelists, true);
}

View File

@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using System.Runtime.InteropServices;
using Content.Server.Administration.Managers;
using Content.Server.Chat.Managers;
using Content.Server.Connection.IPIntel;
using Content.Server.Database;
using Content.Server.GameTicking;
using Content.Server.Preferences.Managers;
@@ -40,6 +41,8 @@ namespace Content.Server.Connection
/// <param name="user">The user to give a temporary bypass.</param>
/// <param name="duration">How long the bypass should last for.</param>
void AddTemporaryConnectBypass(NetUserId user, TimeSpan duration);
void Update();
}
/// <summary>
@@ -57,16 +60,24 @@ namespace Content.Server.Connection
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly IHttpClientHolder _http = default!;
[Dependency] private readonly IAdminManager _adminManager = default!;
private ISawmill _sawmill = default!;
private readonly Dictionary<NetUserId, TimeSpan> _temporaryBypasses = [];
private IPIntel.IPIntel _ipintel = default!;
public void PostInit()
{
InitializeWhitelist();
}
public void Initialize()
{
_sawmill = _logManager.GetSawmill("connections");
_ipintel = new IPIntel.IPIntel(new IPIntelApi(_http, _cfg), _db, _cfg, _logManager, _chatManager, _gameTiming);
_netMgr.Connecting += NetMgrOnConnecting;
_netMgr.AssignUserIdCallback = AssignUserIdCallback;
_plyMgr.PlayerStatusChanged += PlayerStatusChanged;
@@ -83,6 +94,11 @@ namespace Content.Server.Connection
time = newTime;
}
public void Update()
{
_ipintel.Update();
}
/*
private async Task<NetApproval> HandleApproval(NetApprovalEventArgs eventArgs)
{
@@ -291,7 +307,7 @@ namespace Content.Server.Connection
{
_sawmill.Error("Whitelist enabled but no whitelists loaded.");
// Misconfigured, deny everyone.
return (ConnectionDenyReason.Whitelist, Loc.GetString("whitelist-misconfigured"), null);
return (ConnectionDenyReason.Whitelist, Loc.GetString("generic-misconfigured"), null);
}
foreach (var whitelist in _whitelists)
@@ -314,6 +330,15 @@ namespace Content.Server.Connection
}
}
// ALWAYS keep this at the end, to preserve the API limit.
if (_cfg.GetCVar(CCVars.GameIPIntelEnabled) && adminData == null)
{
var result = await _ipintel.IsVpnOrProxy(e);
if (result.IsBad)
return (ConnectionDenyReason.IPChecks, result.Reason, null);
}
return null;
}

View File

@@ -0,0 +1,385 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Threading.Tasks;
using Content.Server.Chat.Managers;
using Content.Server.Database;
using Content.Shared.CCVar;
using Content.Shared.Players.PlayTimeTracking;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Connection.IPIntel;
// Handles checking/warning if the connecting IP address is sus.
public sealed class IPIntel
{
private readonly IIPIntelApi _api;
private readonly IServerDbManager _db;
private readonly IChatManager _chatManager;
private readonly IGameTiming _gameTiming;
private readonly ISawmill _sawmill;
public IPIntel(IIPIntelApi api,
IServerDbManager db,
IConfigurationManager cfg,
ILogManager logManager,
IChatManager chatManager,
IGameTiming gameTiming)
{
_api = api;
_db = db;
_chatManager = chatManager;
_gameTiming = gameTiming;
_sawmill = logManager.GetSawmill("ipintel");
cfg.OnValueChanged(CCVars.GameIPIntelEmail, b => _contactEmail = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelRejectUnknown, b => _rejectUnknown = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelRejectBad, b => _rejectBad = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelRejectRateLimited, b => _rejectLimited = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelMaxMinute, b => _minute.Limit = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelMaxDay, b => _day.Limit = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelBackOffSeconds, b => _backoffSeconds = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelCleanupMins, b => _cleanupMins = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelBadRating, b => _rating = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelCacheLength, b => _cacheDays = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelExemptPlaytime, b => _exemptPlaytime = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelAlertAdminReject, b => _alertAdminReject = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelAlertAdminWarnRating, b => _alertAdminWarn = b, true);
}
internal struct Ratelimits
{
public bool RateLimited;
public bool LimitHasBeenHandled;
public int CurrentRequests;
public int Limit;
public TimeSpan LastRatelimited;
}
// Self-managed preemptive rate limits.
private Ratelimits _day;
private Ratelimits _minute;
// Next time we need to clean the database of stale cached IPIntel results.
private TimeSpan _nextClean;
// Responsive backoff if we hit a Too Many Requests API error.
private int _failedRequests;
private TimeSpan _releasePeriod;
// CCVars
private string? _contactEmail;
private bool _rejectUnknown;
private bool _rejectBad;
private bool _rejectLimited;
private bool _alertAdminReject;
private int _backoffSeconds;
private int _cleanupMins;
private TimeSpan _cacheDays;
private TimeSpan _exemptPlaytime;
private float _rating;
private float _alertAdminWarn;
public async Task<(bool IsBad, string Reason)> IsVpnOrProxy(NetConnectingArgs e)
{
// Check Exemption flags, let them skip if they have them.
var flags = await _db.GetBanExemption(e.UserId);
if ((flags & (ServerBanExemptFlags.Datacenter | ServerBanExemptFlags.BlacklistedRange)) != 0)
{
return (false, string.Empty);
}
// Check playtime, if 0 we skip this check. If player has more playtime then _exemptPlaytime is configured for then they get to skip this check.
// Helps with saving your limited request limit.
if (_exemptPlaytime != TimeSpan.Zero)
{
var overallTime = ( await _db.GetPlayTimes(e.UserId)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall);
if (overallTime != null && overallTime.TimeSpent >= _exemptPlaytime)
{
return (false, string.Empty);
}
}
var ip = e.IP.Address;
var username = e.UserName;
// Is this a local ip address?
if (IsAddressReservedIpv4(ip) || IsAddressReservedIpv6(ip))
{
_sawmill.Warning($"{e.UserName} joined using a local address. Do you need IPIntel? Or is something terribly misconfigured on your server? Trusting this connection.");
return (false, string.Empty);
}
// Check our cache
var query = await _db.GetIPIntelCache(ip);
// Does it exist?
if (query != null)
{
// Skip to score check if result is older than _cacheDays
if (DateTime.UtcNow - query.Time <= _cacheDays)
{
var score = query.Score;
return ScoreCheck(score, username);
}
}
// Ensure our contact email is good to use.
if (string.IsNullOrEmpty(_contactEmail) || !_contactEmail.Contains('@') || !_contactEmail.Contains('.'))
{
_sawmill.Error("IPIntel is enabled, but contact email is empty or not a valid email, treating this connection like an unknown IPIntel response.");
return _rejectUnknown ? (true, Loc.GetString("generic-misconfigured")) : (false, string.Empty);
}
var apiResult = await QueryIPIntelRateLimited(ip);
switch (apiResult.Code)
{
case IPIntelResultCode.Success:
await Task.Run(() => _db.UpsertIPIntelCache(DateTime.UtcNow, ip, apiResult.Score));
return ScoreCheck(apiResult.Score, username);
case IPIntelResultCode.RateLimited:
return _rejectLimited ? (true, Loc.GetString("ipintel-server-ratelimited")) : (false, string.Empty);
case IPIntelResultCode.Errored:
return _rejectUnknown ? (true, Loc.GetString("ipintel-unknown")) : (false, string.Empty);
default:
throw new ArgumentOutOfRangeException();
}
}
public async Task<IPIntelResult> QueryIPIntelRateLimited(IPAddress ip)
{
IncrementAndTestRateLimit(ref _day, TimeSpan.FromDays(1), "daily");
IncrementAndTestRateLimit(ref _minute, TimeSpan.FromMinutes(1), "minute");
if (_minute.RateLimited || _day.RateLimited || CheckSuddenRateLimit())
return new IPIntelResult(0, IPIntelResultCode.RateLimited);
// Info about flag B: https://getipintel.net/free-proxy-vpn-tor-detection-api/#flagsb
// TLDR: We don't care about knowing if a connection is compromised.
// We just want to know if it's a vpn. This also speeds up the request by quite a bit. (A full scan can take 200ms to 5 seconds. This will take at most 120ms)
using var request = await _api.GetIPScore(ip);
if (request.StatusCode == HttpStatusCode.TooManyRequests)
{
_sawmill.Warning($"We hit the IPIntel request limit at some point. (Current limit count: Minute: {_minute.CurrentRequests} Day: {_day.CurrentRequests})");
CalculateSuddenRatelimit();
return new IPIntelResult(0, IPIntelResultCode.RateLimited);
}
var response = await request.Content.ReadAsStringAsync();
var score = Parse.Float(response);
if (request.StatusCode == HttpStatusCode.OK)
{
_failedRequests = 0;
return new IPIntelResult(score, IPIntelResultCode.Success);
}
if (ErrorMessages.TryGetValue(response, out var errorMessage))
{
_sawmill.Error($"IPIntel returned error {response}: {errorMessage}");
}
else
{
// Oh boy, we don't know this error.
_sawmill.Error($"IPIntel returned {response} (Status code: {request.StatusCode})... we don't know what this error code is. Please make an issue in upstream!");
}
return new IPIntelResult(0, IPIntelResultCode.Errored);
}
private bool CheckSuddenRateLimit()
{
return _failedRequests >= 1 && _releasePeriod > _gameTiming.RealTime;
}
private void CalculateSuddenRatelimit()
{
_failedRequests++;
_releasePeriod = _gameTiming.RealTime + TimeSpan.FromSeconds(_failedRequests * _backoffSeconds);
}
private static readonly Dictionary<string, string> ErrorMessages = new()
{
["-1"] = "Invalid/No input.",
["-2"] = "Invalid IP address.",
["-3"] = "Unroutable address / private address given to the api. Make an issue in upstream as it should have been handled.",
["-4"] = "Unable to reach IPIntel database. Perhaps it's down?",
["-5"] = "Server's IP/Contact may have been banned, go to getipintel.net and make contact to be unbanned.",
["-6"] = "You did not provide any contact information with your query or the contact information is invalid.",
};
private void IncrementAndTestRateLimit(ref Ratelimits ratelimits, TimeSpan expireInterval, string name)
{
if (ratelimits.CurrentRequests < ratelimits.Limit)
{
ratelimits.CurrentRequests += 1;
return;
}
if (ShouldLiftRateLimit(in ratelimits, expireInterval))
{
_sawmill.Info($"IPIntel {name} rate limit lifted. We are back to normal.");
ratelimits.RateLimited = false;
ratelimits.CurrentRequests = 0;
ratelimits.LimitHasBeenHandled = false;
return;
}
if (ratelimits.LimitHasBeenHandled)
return;
_sawmill.Warning($"We just hit our last {name} IPIntel limit ({ratelimits.Limit})");
ratelimits.RateLimited = true;
ratelimits.LimitHasBeenHandled = true;
ratelimits.LastRatelimited = _gameTiming.RealTime;
}
private bool ShouldLiftRateLimit(in Ratelimits ratelimits, TimeSpan liftingTime)
{
// Should we raise this limit now?
return ratelimits.RateLimited && _gameTiming.RealTime >= ratelimits.LastRatelimited + liftingTime;
}
private (bool, string Empty) ScoreCheck(float score, string username)
{
var decisionIsReject = score > _rating;
if (_alertAdminWarn != 0f && _alertAdminWarn < score && !decisionIsReject)
{
_chatManager.SendAdminAlert(Loc.GetString("admin-alert-ipintel-warning",
("player", username),
("percent", Math.Round(score))));
}
if (!decisionIsReject)
return (false, string.Empty);
if (_alertAdminReject)
{
_chatManager.SendAdminAlert(Loc.GetString("admin-alert-ipintel-blocked",
("player", username),
("percent", Math.Round(score))));
}
return _rejectBad ? (true, Loc.GetString("ipintel-suspicious")) : (false, string.Empty);
}
public void Update()
{
if (_gameTiming.RealTime >= _nextClean)
{
_nextClean = _gameTiming.RealTime + TimeSpan.FromMinutes(_cleanupMins);
_db.CleanIPIntelCache(_cacheDays);
}
}
// Stolen from Lidgren.Network (Space Wizards Edition) (NetReservedAddress.cs)
// Modified with IPV6 on top
private static int Ipv4(byte a, byte b, byte c, byte d)
{
return (a << 24) | (b << 16) | (c << 8) | d;
}
// From miniupnpc
private static readonly (int ip, int mask)[] ReservedRangesIpv4 =
[
// @formatter:off
(Ipv4(0, 0, 0, 0), 8 ), // RFC1122 "This host on this network"
(Ipv4(10, 0, 0, 0), 8 ), // RFC1918 Private-Use
(Ipv4(100, 64, 0, 0), 10), // RFC6598 Shared Address Space
(Ipv4(127, 0, 0, 0), 8 ), // RFC1122 Loopback
(Ipv4(169, 254, 0, 0), 16), // RFC3927 Link-Local
(Ipv4(172, 16, 0, 0), 12), // RFC1918 Private-Use
(Ipv4(192, 0, 0, 0), 24), // RFC6890 IETF Protocol Assignments
(Ipv4(192, 0, 2, 0), 24), // RFC5737 Documentation (TEST-NET-1)
(Ipv4(192, 31, 196, 0), 24), // RFC7535 AS112-v4
(Ipv4(192, 52, 193, 0), 24), // RFC7450 AMT
(Ipv4(192, 88, 99, 0), 24), // RFC7526 6to4 Relay Anycast
(Ipv4(192, 168, 0, 0), 16), // RFC1918 Private-Use
(Ipv4(192, 175, 48, 0), 24), // RFC7534 Direct Delegation AS112 Service
(Ipv4(198, 18, 0, 0), 15), // RFC2544 Benchmarking
(Ipv4(198, 51, 100, 0), 24), // RFC5737 Documentation (TEST-NET-2)
(Ipv4(203, 0, 113, 0), 24), // RFC5737 Documentation (TEST-NET-3)
(Ipv4(224, 0, 0, 0), 4 ), // RFC1112 Multicast
(Ipv4(240, 0, 0, 0), 4 ), // RFC1112 Reserved for Future Use + RFC919 Limited Broadcast
// @formatter:on
];
private static UInt128 ToAddressBytes(string ip)
{
return BinaryPrimitives.ReadUInt128BigEndian(IPAddress.Parse(ip).GetAddressBytes());
}
private static readonly (UInt128 ip, int mask)[] ReservedRangesIpv6 =
[
(ToAddressBytes("::1"), 128), // "This host on this network"
(ToAddressBytes("::ffff:0:0"), 96), // IPv4-mapped addresses
(ToAddressBytes("::ffff:0:0:0"), 96), // IPv4-translated addresses
(ToAddressBytes("64:ff9b:1::"), 48), // IPv4/IPv6 translation
(ToAddressBytes("100::"), 64), // Discard prefix
(ToAddressBytes("2001:20::"), 28), // ORCHIDv2
(ToAddressBytes("2001:db8::"), 32), // Addresses used in documentation and example source code
(ToAddressBytes("3fff::"), 20), // Addresses used in documentation and example source code
(ToAddressBytes("5f00::"), 16), // IPv6 Segment Routing (SRv6)
(ToAddressBytes("fc00::"), 7), // Unique local address
];
internal static bool IsAddressReservedIpv4(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetwork)
return false;
Span<byte> ipBitsByte = stackalloc byte[4];
address.TryWriteBytes(ipBitsByte, out _);
var ipBits = BinaryPrimitives.ReadInt32BigEndian(ipBitsByte);
foreach (var (reservedIp, maskBits) in ReservedRangesIpv4)
{
var mask = uint.MaxValue << (32 - maskBits);
if ((ipBits & mask) == (reservedIp & mask))
return true;
}
return false;
}
internal static bool IsAddressReservedIpv6(IPAddress address)
{
if (address.AddressFamily != AddressFamily.InterNetworkV6)
return false;
if (address.IsIPv4MappedToIPv6)
return IsAddressReservedIpv4(address.MapToIPv4());
Span<byte> ipBitsByte = stackalloc byte[16];
address.TryWriteBytes(ipBitsByte, out _);
var ipBits = BinaryPrimitives.ReadInt128BigEndian(ipBitsByte);
foreach (var (reservedIp, maskBits) in ReservedRangesIpv6)
{
var mask = UInt128.MaxValue << (128 - maskBits);
if (((UInt128) ipBits & mask ) == (reservedIp & mask))
return true;
}
return false;
}
public readonly record struct IPIntelResult(float Score, IPIntelResultCode Code);
public enum IPIntelResultCode : byte
{
Success = 0,
RateLimited,
Errored,
}
}

View File

@@ -0,0 +1,40 @@
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Content.Shared.CCVar;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
namespace Content.Server.Connection.IPIntel;
public interface IIPIntelApi
{
Task<HttpResponseMessage> GetIPScore(IPAddress ip);
}
public sealed class IPIntelApi : IIPIntelApi
{
// Holds-The-HttpClient
private readonly IHttpClientHolder _http;
// CCvars
private string? _contactEmail;
private string? _baseUrl;
private string? _flags;
public IPIntelApi(
IHttpClientHolder http,
IConfigurationManager cfg)
{
_http = http;
cfg.OnValueChanged(CCVars.GameIPIntelEmail, b => _contactEmail = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelBase, b => _baseUrl = b, true);
cfg.OnValueChanged(CCVars.GameIPIntelFlags, b => _flags = b, true);
}
public Task<HttpResponseMessage> GetIPScore(IPAddress ip)
{
return _http.Client.GetAsync($"{_baseUrl}/check.php?ip={ip}&contact={_contactEmail}&flags={_flags}");
}
}

View File

@@ -1720,6 +1720,70 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id}
#endregion
# region IPIntel
public async Task<bool> UpsertIPIntelCache(DateTime time, IPAddress ip, float score)
{
while (true)
{
try
{
await using var db = await GetDb();
var existing = await db.DbContext.IPIntelCache
.Where(w => ip.Equals(w.Address))
.SingleOrDefaultAsync();
if (existing == null)
{
var newCache = new IPIntelCache
{
Time = time,
Address = ip,
Score = score,
};
db.DbContext.IPIntelCache.Add(newCache);
}
else
{
existing.Time = time;
existing.Score = score;
}
await Task.Delay(5000);
await db.DbContext.SaveChangesAsync();
return true;
}
catch (DbUpdateException)
{
_opsLog.Warning("IPIntel UPSERT failed with a db exception... retrying.");
}
}
}
public async Task<IPIntelCache?> GetIPIntelCache(IPAddress ip)
{
await using var db = await GetDb();
return await db.DbContext.IPIntelCache
.SingleOrDefaultAsync(w => ip.Equals(w.Address));
}
public async Task<bool> CleanIPIntelCache(TimeSpan range)
{
await using var db = await GetDb();
await db.DbContext.IPIntelCache
.Where(w => DateTime.UtcNow - w.Time >= range)
.ExecuteDeleteAsync();
await db.DbContext.SaveChangesAsync();
return true;
}
#endregion
// SQLite returns DateTime as Kind=Unspecified, Npgsql actually knows for sure it's Kind=Utc.
// Normalize DateTimes here so they're always Utc. Thanks.
protected abstract DateTime NormalizeDatabaseTime(DateTime time);

View File

@@ -322,6 +322,14 @@ namespace Content.Server.Database
#endregion
#region IPintel
Task<bool> UpsertIPIntelCache(DateTime time, IPAddress ip, float score);
Task<IPIntelCache?> GetIPIntelCache(IPAddress ip);
Task<bool> CleanIPIntelCache(TimeSpan range);
#endregion
#region DB Notifications
void SubscribeToNotifications(Action<DatabaseNotification> handler);
@@ -991,6 +999,23 @@ namespace Content.Server.Database
return RunDbCommand(() => _db.RemoveJobWhitelist(player, job));
}
public Task<bool> UpsertIPIntelCache(DateTime time, IPAddress ip, float score)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.UpsertIPIntelCache(time, ip, score));
}
public Task<IPIntelCache?> GetIPIntelCache(IPAddress ip)
{
return RunDbCommand(() => _db.GetIPIntelCache(ip));
}
public Task<bool> CleanIPIntelCache(TimeSpan range)
{
DbWriteOpsMetric.Inc();
return RunDbCommand(() => _db.CleanIPIntelCache(range));
}
public void SubscribeToNotifications(Action<DatabaseNotification> handler)
{
lock (_notificationHandlers)

View File

@@ -47,6 +47,7 @@ namespace Content.Server.Entry
private PlayTimeTrackingManager? _playTimeTracking;
private IEntitySystemManager? _sysMan;
private IServerDbManager? _dbManager;
private IConnectionManager? _connectionManager;
/// <inheritdoc />
public override void Init()
@@ -91,6 +92,7 @@ namespace Content.Server.Entry
_voteManager = IoCManager.Resolve<IVoteManager>();
_updateManager = IoCManager.Resolve<ServerUpdateManager>();
_playTimeTracking = IoCManager.Resolve<PlayTimeTrackingManager>();
_connectionManager = IoCManager.Resolve<IConnectionManager>();
_sysMan = IoCManager.Resolve<IEntitySystemManager>();
_dbManager = IoCManager.Resolve<IServerDbManager>();
@@ -166,6 +168,7 @@ namespace Content.Server.Entry
case ModUpdateLevel.FramePostEngine:
_updateManager.Update();
_playTimeTracking?.Update();
_connectionManager?.Update();
break;
}
}

View File

@@ -73,6 +73,7 @@ namespace Content.Server.IoC
IoCManager.Register<PlayerRateLimitManager>();
IoCManager.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
IoCManager.Register<MappingManager>();
IoCManager.Register<ConnectionManager>();
}
}
}

View File

@@ -240,6 +240,106 @@ public sealed partial class CCVars
public static readonly CVarDef<bool> BypassBabyJailWhitelist =
CVarDef.Create("game.baby_jail.whitelisted_can_bypass", true, CVar.SERVERONLY);
/// <summary>
/// Enable IPIntel for blocking VPN connections from new players.
/// </summary>
public static readonly CVarDef<bool> GameIPIntelEnabled =
CVarDef.Create("game.ipintel_enabled", false, CVar.SERVERONLY);
/// <summary>
/// Whether clients which are flagged as a VPN will be denied
/// </summary>
public static readonly CVarDef<bool> GameIPIntelRejectBad =
CVarDef.Create("game.ipintel_reject_bad", true, CVar.SERVERONLY);
/// <summary>
/// Whether clients which cannot be checked due to a rate limit will be denied
/// </summary>
public static readonly CVarDef<bool> GameIPIntelRejectRateLimited =
CVarDef.Create("game.ipintel_reject_ratelimited", false, CVar.SERVERONLY);
/// <summary>
/// Whether clients which cannot be checked due to an error of some form will be denied
/// </summary>
public static readonly CVarDef<bool> GameIPIntelRejectUnknown =
CVarDef.Create("game.ipintel_reject_unknown", false, CVar.SERVERONLY);
/// <summary>
/// Should an admin message be made if the connection got rejected cause of ipintel?
/// </summary>
public static readonly CVarDef<bool> GameIPIntelAlertAdminReject =
CVarDef.Create("game.ipintel_alert_admin_rejected", false, CVar.SERVERONLY);
/// <summary>
/// A contact email to be sent along with the request. Required by IPIntel
/// </summary>
public static readonly CVarDef<string> GameIPIntelEmail =
CVarDef.Create("game.ipintel_contact_email", string.Empty, CVar.SERVERONLY | CVar.CONFIDENTIAL);
/// <summary>
/// The URL to IPIntel to make requests to. If you pay for more queries this is what you want to change.
/// </summary>
public static readonly CVarDef<string> GameIPIntelBase =
CVarDef.Create("game.ipintel_baseurl", "https://check.getipintel.net", CVar.SERVERONLY);
/// <summary>
/// The flags to use in the request to IPIntel, please look here for more info. https://getipintel.net/free-proxy-vpn-tor-detection-api/#optional_settings
/// Note: Some flags may increase the chances of false positives and request time. The default should be fine for most servers.
/// </summary>
public static readonly CVarDef<string> GameIPIntelFlags =
CVarDef.Create("game.ipintel_flags", "b", CVar.SERVERONLY);
/// <summary>
/// Maximum amount of requests per Minute. For free you get 15.
/// </summary>
public static readonly CVarDef<int> GameIPIntelMaxMinute =
CVarDef.Create("game.ipintel_request_limit_minute", 15, CVar.SERVERONLY);
/// <summary>
/// Maximum amount of requests per Day. For free you get 500.
/// </summary>
public static readonly CVarDef<int> GameIPIntelMaxDay =
CVarDef.Create("game.ipintel_request_limit_daily", 500, CVar.SERVERONLY);
/// <summary>
/// Amount of seconds to add to the exponential backoff with every failed request.
/// </summary>
public static readonly CVarDef<int> GameIPIntelBackOffSeconds =
CVarDef.Create("game.ipintel_request_backoff_seconds", 30, CVar.SERVERONLY);
/// <summary>
/// How much time should pass before we attempt to cleanup the IPIntel table for old ip addresses?
/// </summary>
public static readonly CVarDef<int> GameIPIntelCleanupMins =
CVarDef.Create("game.ipintel_database_cleanup_mins", 15, CVar.SERVERONLY);
/// <summary>
/// How long to store results in the cache before they must be retrieved again in days.
/// </summary>
public static readonly CVarDef<TimeSpan> GameIPIntelCacheLength =
CVarDef.Create("game.ipintel_cache_length", TimeSpan.FromDays(7), CVar.SERVERONLY);
/// <summary>
/// Amount of playtime in minutes to be exempt from an IP check. 0 to search everyone. 5 hours by default.
/// <remarks>
/// Trust me you want one.
/// </remarks>>
/// </summary>
public static readonly CVarDef<TimeSpan> GameIPIntelExemptPlaytime =
CVarDef.Create("game.ipintel_exempt_playtime", TimeSpan.FromMinutes(300), CVar.SERVERONLY);
/// <summary>
/// Rating to reject at. Anything equal to or higher than this will reject the connection.
/// </summary>
public static readonly CVarDef<float> GameIPIntelBadRating =
CVarDef.Create("game.ipintel_bad_rating", 0.95f, CVar.SERVERONLY);
/// <summary>
/// Rating to send an admin warning over, but not reject the connection. Set to 0 to disable
/// </summary>
public static readonly CVarDef<float> GameIPIntelAlertAdminWarnRating =
CVarDef.Create("game.ipintel_alert_admin_warn_rating", 0f, CVar.SERVERONLY);
/// <summary>
/// Make people bonk when trying to climb certain objects like tables.
/// </summary>

View File

@@ -0,0 +1,269 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Content.Server.Chat.Managers;
using Content.Server.Connection.IPIntel;
using Content.Server.Database;
using Content.Shared.CCVar;
using Moq;
using NUnit.Framework;
using Robust.Shared.Configuration;
using Robust.Shared.Log;
using Robust.Shared.Timing;
using Robust.UnitTesting;
// ReSharper disable AccessToModifiedClosure
namespace Content.Tests.Server.Connection;
[TestFixture, TestOf(typeof(IPIntel))]
[Parallelizable(ParallelScope.All)]
public static class IPIntelTest
{
private static readonly IPAddress TestIp = IPAddress.Parse("192.0.2.1");
private static void CreateIPIntel(
out IPIntel ipIntel,
out IConfigurationManager cfg,
Func<HttpResponseMessage> apiResponse,
Func<TimeSpan> realTime = null)
{
var dbManager = new Mock<IServerDbManager>();
var gameTimingMock = new Mock<IGameTiming>();
gameTimingMock.SetupGet(gt => gt.RealTime)
.Returns(realTime ?? (() => TimeSpan.Zero));
var logManager = new LogManager();
var gameTiming = gameTimingMock.Object;
cfg = MockInterfaces.MakeConfigurationManager(gameTiming, logManager, loadCvarsFromTypes: [typeof(CCVars)]);
ipIntel = new IPIntel(
new FakeIPIntelApi(apiResponse),
dbManager.Object,
cfg,
logManager,
new Mock<IChatManager>().Object,
gameTiming
);
}
[Test]
public static async Task TestSuccess()
{
CreateIPIntel(
out var ipIntel,
out _,
RespondSuccess);
var result = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.Multiple(() =>
{
Assert.That(result.Score, Is.EqualTo(0.5f).Within(0.01f));
Assert.That(result.Code, Is.EqualTo(IPIntel.IPIntelResultCode.Success));
});
}
[Test]
public static async Task KnownRateLimitMinuteTest()
{
var source = RespondSuccess;
var time = TimeSpan.Zero;
CreateIPIntel(
out var ipIntel,
out var cfg,
() => source(),
() => time);
cfg.SetCVar(CCVars.GameIPIntelMaxMinute, 9);
for (var i = 0; i < 9; i++)
{
var result = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(result.Code, Is.EqualTo(IPIntel.IPIntelResultCode.Success));
}
source = RespondTestFailed;
var shouldBeRateLimited = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(shouldBeRateLimited.Code, Is.EqualTo(IPIntel.IPIntelResultCode.RateLimited));
time += TimeSpan.FromMinutes(1.5);
source = RespondSuccess;
var shouldSucceed = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(shouldSucceed.Code, Is.EqualTo(IPIntel.IPIntelResultCode.Success));
}
[Test]
public static async Task KnownRateLimitMinuteTimingTest()
{
var source = RespondSuccess;
var time = TimeSpan.Zero;
CreateIPIntel(
out var ipIntel,
out var cfg,
() => source(),
() => time);
cfg.SetCVar(CCVars.GameIPIntelMaxMinute, 1);
// First query succeeds.
var result = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(result.Code, Is.EqualTo(IPIntel.IPIntelResultCode.Success));
// Second is rate limited via known limit.
source = RespondTestFailed;
result = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(result.Code, Is.EqualTo(IPIntel.IPIntelResultCode.RateLimited));
// Move 30 seconds into the future, should not be enough to unratelimit.
time += TimeSpan.FromSeconds(30);
var shouldBeRateLimited = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(shouldBeRateLimited.Code, Is.EqualTo(IPIntel.IPIntelResultCode.RateLimited));
// Should be available again.
source = RespondSuccess;
time += TimeSpan.FromSeconds(35);
var shouldSucceed = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(shouldSucceed.Code, Is.EqualTo(IPIntel.IPIntelResultCode.Success));
}
[Test]
public static async Task SuddenRateLimitTest()
{
var time = TimeSpan.Zero;
var source = RespondRateLimited;
CreateIPIntel(
out var ipIntel,
out _,
() => source(),
() => time);
var test = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(test.Code, Is.EqualTo(IPIntel.IPIntelResultCode.RateLimited));
source = RespondTestFailed;
test = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(test.Code, Is.EqualTo(IPIntel.IPIntelResultCode.RateLimited));
// King crimson idk I didn't watch JoJo past part 2.
time += TimeSpan.FromMinutes(2);
source = RespondSuccess;
test = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(test.Code, Is.EqualTo(IPIntel.IPIntelResultCode.Success));
}
[Test]
public static async Task SuddenRateLimitExponentialBackoffTest()
{
var time = TimeSpan.Zero;
var source = RespondRateLimited;
CreateIPIntel(
out var ipIntel,
out _,
() => source(),
() => time);
IPIntel.IPIntelResult test;
for (var i = 0; i < 5; i++)
{
time += TimeSpan.FromHours(1);
test = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(test.Code, Is.EqualTo(IPIntel.IPIntelResultCode.RateLimited));
}
// After 5 sequential failed attempts, 1 minute should not be enough to get past the exponential backoff.
time += TimeSpan.FromMinutes(1);
source = RespondTestFailed;
test = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(test.Code, Is.EqualTo(IPIntel.IPIntelResultCode.RateLimited));
}
[Test]
public static async Task ErrorTest()
{
CreateIPIntel(
out var ipIntel,
out _,
RespondError);
var resp = await ipIntel.QueryIPIntelRateLimited(TestIp);
Assert.That(resp.Code, Is.EqualTo(IPIntel.IPIntelResultCode.Errored));
}
[Test]
[TestCase("0.0.0.0", ExpectedResult = true)]
[TestCase("0.3.5.7", ExpectedResult = true)]
[TestCase("127.0.0.1", ExpectedResult = true)]
[TestCase("11.0.0.0", ExpectedResult = false)]
[TestCase("10.0.1.0", ExpectedResult = true)]
[TestCase("192.168.5.12", ExpectedResult = true)]
[TestCase("192.167.0.1", ExpectedResult = false)]
// Not an IPv4!
[TestCase("::1", ExpectedResult = false)]
public static bool TestIsReservedIpv4(string ipAddress)
{
return IPIntel.IsAddressReservedIpv4(IPAddress.Parse(ipAddress));
}
[Test]
// IPv4-mapped IPv6 should use IPv4 behavior.
[TestCase("::ffff:0.0.0.0", ExpectedResult = true)]
[TestCase("::ffff:0.3.5.7", ExpectedResult = true)]
[TestCase("::ffff:127.0.0.1", ExpectedResult = true)]
[TestCase("::ffff:11.0.0.0", ExpectedResult = false)]
[TestCase("::ffff:10.0.1.0", ExpectedResult = true)]
[TestCase("::ffff:192.168.5.12", ExpectedResult = true)]
[TestCase("::ffff:192.167.0.1", ExpectedResult = false)]
// Regular IPv6 tests.
[TestCase("::1", ExpectedResult = true)]
[TestCase("2001:db8::01", ExpectedResult = true)]
[TestCase("2a01:4f8:252:4425::1234", ExpectedResult = false)]
// Not an IPv6!
[TestCase("127.0.0.1", ExpectedResult = false)]
public static bool TestIsReservedIpv6(string ipAddress)
{
return IPIntel.IsAddressReservedIpv6(IPAddress.Parse(ipAddress));
}
private static HttpResponseMessage RespondSuccess()
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("0.5"),
};
}
private static HttpResponseMessage RespondRateLimited()
{
return new HttpResponseMessage(HttpStatusCode.TooManyRequests);
}
private static HttpResponseMessage RespondTestFailed()
{
throw new InvalidOperationException("API should not be queried at this part of the test.");
}
private static HttpResponseMessage RespondError()
{
return new HttpResponseMessage(HttpStatusCode.BadRequest)
{
Content = new StringContent("-4"),
};
}
}
internal sealed class FakeIPIntelApi(Func<HttpResponseMessage> response) : IIPIntelApi
{
public Task<HttpResponseMessage> GetIPScore(IPAddress ip)
{
return Task.FromResult(response());
}
}

View File

@@ -1 +1,3 @@
admin-alert-shared-connection = {$player} is sharing a connection with {$otherCount} connected player(s): {$otherList}
admin-alert-ipintel-blocked = {$player} was rejected from joining due to their IP having a {TOSTRING($percent, "P0")} confidence of being a VPN/Datacenter.
admin-alert-ipintel-warning = {$player} IP has a {TOSTRING($percent, "P0")} confidence of being a VPN/Datacenter. Please watch them.

View File

@@ -35,7 +35,6 @@ whitelist-manual = You are not whitelisted on this server.
whitelist-blacklisted = You are blacklisted from this server.
whitelist-always-deny = You are not allowed to join this server.
whitelist-fail-prefix = Not whitelisted: {$msg}
whitelist-misconfigured = The server is misconfigured and is not accepting players. Please contact the server owner and try again later.
cmd-blacklistadd-desc = Adds the player with the given username to the server blacklist.
cmd-blacklistadd-help = Usage: blacklistadd <username>
@@ -55,3 +54,9 @@ baby-jail-account-denied = This server is a newbie server, intended for new play
baby-jail-account-denied-reason = This server is a newbie server, intended for new players and those who want to help them. New connections by accounts that are too old or are not on a whitelist are not accepted. Check out some other servers and see everything Space Station 14 has to offer. Have fun! Reason: "{$reason}"
baby-jail-account-reason-account = Your Space Station 14 account is too old. It must be younger than {$minutes} minutes
baby-jail-account-reason-overall = Your overall playtime on the server must be younger than {$minutes} $minutes
generic-misconfigured = The server is misconfigured and is not accepting players. Please contact the server owner and try again later.
ipintel-server-ratelimited = This server uses a security system with external verification, which has reached its maximum verification limit. Please contact the administration team of the server for assistance and try again later.
ipintel-unknown = This server uses a security system with external verification, but it encountered an error. Please contact the administration team of the server for assistance and try again later.
ipintel-suspicious = You seem to be connecting through a datacenter or VPN. For administrative reasons we do not allow VPN connections to play. Please contact the administration team of the server for assistance if you believe this is false.