Add IPIntel API support. (#33339)
Co-authored-by: PJB3005 <pieterjan.briers+git@gmail.com>
This commit is contained in:
2104
Content.Server.Database/Migrations/Postgres/20241122174243_IPIntel.Designer.cs
generated
Normal file
2104
Content.Server.Database/Migrations/Postgres/20241122174243_IPIntel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
2028
Content.Server.Database/Migrations/Sqlite/20241122174236_IPIntel.Designer.cs
generated
Normal file
2028
Content.Server.Database/Migrations/Sqlite/20241122174236_IPIntel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed partial class ConnectionManager
|
||||
{
|
||||
private PlayerConnectionWhitelistPrototype[]? _whitelists;
|
||||
|
||||
public void PostInit()
|
||||
private void InitializeWhitelist()
|
||||
{
|
||||
_cfg.OnValueChanged(CCVars.WhitelistPrototypeList, UpdateWhitelists, true);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
385
Content.Server/Connection/IPIntel/IPIntel.cs
Normal file
385
Content.Server/Connection/IPIntel/IPIntel.cs
Normal 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,
|
||||
}
|
||||
}
|
||||
40
Content.Server/Connection/IPIntel/IPIntelAPI.cs
Normal file
40
Content.Server/Connection/IPIntel/IPIntelAPI.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ namespace Content.Server.IoC
|
||||
IoCManager.Register<PlayerRateLimitManager>();
|
||||
IoCManager.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
|
||||
IoCManager.Register<MappingManager>();
|
||||
IoCManager.Register<ConnectionManager>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
269
Content.Tests/Server/Connection/IPIntelTest.cs
Normal file
269
Content.Tests/Server/Connection/IPIntelTest.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user