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 =>
|
modelBuilder.Entity("Content.Server.Database.Job", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
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);
|
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 =>
|
modelBuilder.Entity("Content.Server.Database.Job", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ namespace Content.Server.Database
|
|||||||
public DbSet<AdminMessage> AdminMessages { get; set; } = null!;
|
public DbSet<AdminMessage> AdminMessages { get; set; } = null!;
|
||||||
public DbSet<RoleWhitelist> RoleWhitelists { get; set; } = null!;
|
public DbSet<RoleWhitelist> RoleWhitelists { get; set; } = null!;
|
||||||
public DbSet<BanTemplate> BanTemplate { get; set; } = null!;
|
public DbSet<BanTemplate> BanTemplate { get; set; } = null!;
|
||||||
|
public DbSet<IPIntelCache> IPIntelCache { get; set; } = null!;
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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.
|
* 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,
|
BabyJail = 4,
|
||||||
|
/// Results from rejected connections with external API checking tools
|
||||||
|
IPChecks = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ServerBanHit
|
public class ServerBanHit
|
||||||
@@ -1284,4 +1287,28 @@ namespace Content.Server.Database
|
|||||||
return new ImmutableTypedHwid(hwid.Hwid.ToImmutableArray(), hwid.Type);
|
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>()
|
modelBuilder.Entity<Profile>()
|
||||||
.Property(log => log.Markings)
|
.Property(log => log.Markings)
|
||||||
.HasConversion(jsonByteArrayConverter);
|
.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()
|
public override int CountAdminLogs()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public sealed partial class ConnectionManager
|
|||||||
{
|
{
|
||||||
private PlayerConnectionWhitelistPrototype[]? _whitelists;
|
private PlayerConnectionWhitelistPrototype[]? _whitelists;
|
||||||
|
|
||||||
public void PostInit()
|
private void InitializeWhitelist()
|
||||||
{
|
{
|
||||||
_cfg.OnValueChanged(CCVars.WhitelistPrototypeList, UpdateWhitelists, true);
|
_cfg.OnValueChanged(CCVars.WhitelistPrototypeList, UpdateWhitelists, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading.Tasks;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using Content.Server.Administration.Managers;
|
using Content.Server.Administration.Managers;
|
||||||
using Content.Server.Chat.Managers;
|
using Content.Server.Chat.Managers;
|
||||||
|
using Content.Server.Connection.IPIntel;
|
||||||
using Content.Server.Database;
|
using Content.Server.Database;
|
||||||
using Content.Server.GameTicking;
|
using Content.Server.GameTicking;
|
||||||
using Content.Server.Preferences.Managers;
|
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="user">The user to give a temporary bypass.</param>
|
||||||
/// <param name="duration">How long the bypass should last for.</param>
|
/// <param name="duration">How long the bypass should last for.</param>
|
||||||
void AddTemporaryConnectBypass(NetUserId user, TimeSpan duration);
|
void AddTemporaryConnectBypass(NetUserId user, TimeSpan duration);
|
||||||
|
|
||||||
|
void Update();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -57,16 +60,24 @@ namespace Content.Server.Connection
|
|||||||
[Dependency] private readonly IGameTiming _gameTiming = default!;
|
[Dependency] private readonly IGameTiming _gameTiming = default!;
|
||||||
[Dependency] private readonly ILogManager _logManager = default!;
|
[Dependency] private readonly ILogManager _logManager = default!;
|
||||||
[Dependency] private readonly IChatManager _chatManager = default!;
|
[Dependency] private readonly IChatManager _chatManager = default!;
|
||||||
|
[Dependency] private readonly IHttpClientHolder _http = default!;
|
||||||
[Dependency] private readonly IAdminManager _adminManager = default!;
|
[Dependency] private readonly IAdminManager _adminManager = default!;
|
||||||
|
|
||||||
private ISawmill _sawmill = default!;
|
private ISawmill _sawmill = default!;
|
||||||
private readonly Dictionary<NetUserId, TimeSpan> _temporaryBypasses = [];
|
private readonly Dictionary<NetUserId, TimeSpan> _temporaryBypasses = [];
|
||||||
|
private IPIntel.IPIntel _ipintel = default!;
|
||||||
|
|
||||||
|
public void PostInit()
|
||||||
|
{
|
||||||
|
InitializeWhitelist();
|
||||||
|
}
|
||||||
|
|
||||||
public void Initialize()
|
public void Initialize()
|
||||||
{
|
{
|
||||||
_sawmill = _logManager.GetSawmill("connections");
|
_sawmill = _logManager.GetSawmill("connections");
|
||||||
|
|
||||||
|
_ipintel = new IPIntel.IPIntel(new IPIntelApi(_http, _cfg), _db, _cfg, _logManager, _chatManager, _gameTiming);
|
||||||
|
|
||||||
_netMgr.Connecting += NetMgrOnConnecting;
|
_netMgr.Connecting += NetMgrOnConnecting;
|
||||||
_netMgr.AssignUserIdCallback = AssignUserIdCallback;
|
_netMgr.AssignUserIdCallback = AssignUserIdCallback;
|
||||||
_plyMgr.PlayerStatusChanged += PlayerStatusChanged;
|
_plyMgr.PlayerStatusChanged += PlayerStatusChanged;
|
||||||
@@ -83,6 +94,11 @@ namespace Content.Server.Connection
|
|||||||
time = newTime;
|
time = newTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Update()
|
||||||
|
{
|
||||||
|
_ipintel.Update();
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
private async Task<NetApproval> HandleApproval(NetApprovalEventArgs eventArgs)
|
private async Task<NetApproval> HandleApproval(NetApprovalEventArgs eventArgs)
|
||||||
{
|
{
|
||||||
@@ -291,7 +307,7 @@ namespace Content.Server.Connection
|
|||||||
{
|
{
|
||||||
_sawmill.Error("Whitelist enabled but no whitelists loaded.");
|
_sawmill.Error("Whitelist enabled but no whitelists loaded.");
|
||||||
// Misconfigured, deny everyone.
|
// Misconfigured, deny everyone.
|
||||||
return (ConnectionDenyReason.Whitelist, Loc.GetString("whitelist-misconfigured"), null);
|
return (ConnectionDenyReason.Whitelist, Loc.GetString("generic-misconfigured"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var whitelist in _whitelists)
|
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;
|
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
|
#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.
|
// SQLite returns DateTime as Kind=Unspecified, Npgsql actually knows for sure it's Kind=Utc.
|
||||||
// Normalize DateTimes here so they're always Utc. Thanks.
|
// Normalize DateTimes here so they're always Utc. Thanks.
|
||||||
protected abstract DateTime NormalizeDatabaseTime(DateTime time);
|
protected abstract DateTime NormalizeDatabaseTime(DateTime time);
|
||||||
|
|||||||
@@ -322,6 +322,14 @@ namespace Content.Server.Database
|
|||||||
|
|
||||||
#endregion
|
#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
|
#region DB Notifications
|
||||||
|
|
||||||
void SubscribeToNotifications(Action<DatabaseNotification> handler);
|
void SubscribeToNotifications(Action<DatabaseNotification> handler);
|
||||||
@@ -991,6 +999,23 @@ namespace Content.Server.Database
|
|||||||
return RunDbCommand(() => _db.RemoveJobWhitelist(player, job));
|
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)
|
public void SubscribeToNotifications(Action<DatabaseNotification> handler)
|
||||||
{
|
{
|
||||||
lock (_notificationHandlers)
|
lock (_notificationHandlers)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ namespace Content.Server.Entry
|
|||||||
private PlayTimeTrackingManager? _playTimeTracking;
|
private PlayTimeTrackingManager? _playTimeTracking;
|
||||||
private IEntitySystemManager? _sysMan;
|
private IEntitySystemManager? _sysMan;
|
||||||
private IServerDbManager? _dbManager;
|
private IServerDbManager? _dbManager;
|
||||||
|
private IConnectionManager? _connectionManager;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Init()
|
public override void Init()
|
||||||
@@ -91,6 +92,7 @@ namespace Content.Server.Entry
|
|||||||
_voteManager = IoCManager.Resolve<IVoteManager>();
|
_voteManager = IoCManager.Resolve<IVoteManager>();
|
||||||
_updateManager = IoCManager.Resolve<ServerUpdateManager>();
|
_updateManager = IoCManager.Resolve<ServerUpdateManager>();
|
||||||
_playTimeTracking = IoCManager.Resolve<PlayTimeTrackingManager>();
|
_playTimeTracking = IoCManager.Resolve<PlayTimeTrackingManager>();
|
||||||
|
_connectionManager = IoCManager.Resolve<IConnectionManager>();
|
||||||
_sysMan = IoCManager.Resolve<IEntitySystemManager>();
|
_sysMan = IoCManager.Resolve<IEntitySystemManager>();
|
||||||
_dbManager = IoCManager.Resolve<IServerDbManager>();
|
_dbManager = IoCManager.Resolve<IServerDbManager>();
|
||||||
|
|
||||||
@@ -166,6 +168,7 @@ namespace Content.Server.Entry
|
|||||||
case ModUpdateLevel.FramePostEngine:
|
case ModUpdateLevel.FramePostEngine:
|
||||||
_updateManager.Update();
|
_updateManager.Update();
|
||||||
_playTimeTracking?.Update();
|
_playTimeTracking?.Update();
|
||||||
|
_connectionManager?.Update();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ namespace Content.Server.IoC
|
|||||||
IoCManager.Register<PlayerRateLimitManager>();
|
IoCManager.Register<PlayerRateLimitManager>();
|
||||||
IoCManager.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
|
IoCManager.Register<SharedPlayerRateLimitManager, PlayerRateLimitManager>();
|
||||||
IoCManager.Register<MappingManager>();
|
IoCManager.Register<MappingManager>();
|
||||||
|
IoCManager.Register<ConnectionManager>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,106 @@ public sealed partial class CCVars
|
|||||||
public static readonly CVarDef<bool> BypassBabyJailWhitelist =
|
public static readonly CVarDef<bool> BypassBabyJailWhitelist =
|
||||||
CVarDef.Create("game.baby_jail.whitelisted_can_bypass", true, CVar.SERVERONLY);
|
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>
|
/// <summary>
|
||||||
/// Make people bonk when trying to climb certain objects like tables.
|
/// Make people bonk when trying to climb certain objects like tables.
|
||||||
/// </summary>
|
/// </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-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-blacklisted = You are blacklisted from this server.
|
||||||
whitelist-always-deny = You are not allowed to join this server.
|
whitelist-always-deny = You are not allowed to join this server.
|
||||||
whitelist-fail-prefix = Not whitelisted: {$msg}
|
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-desc = Adds the player with the given username to the server blacklist.
|
||||||
cmd-blacklistadd-help = Usage: blacklistadd <username>
|
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-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-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
|
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