From df95be1ce5b8cd939fac825ed3b2c6df48e3e0ff Mon Sep 17 00:00:00 2001 From: Julian Giebel Date: Tue, 20 Aug 2024 23:31:33 +0200 Subject: [PATCH] Kick on ban for entire server group (#28649) * Start work on PostgresNotificationManager Implement initial version of init and listening code * Finish implementing PostgresNotificationManager Implement ban insert trigger * Implement ignoring notifications if the ban was from the same server * Address reviews * Fixes and refactorings Fix typo in migration SQL Pull new code in BanManager out into its own partial file. Unify logic to kick somebody with that when a new ban is placed directly on the server. New bans are now checked against all parameters (IP, HWID) instead of just user ID. Extracted SQLite ban matching code into a new class so that it can mostly be re-used by the ban notification code. No copy-paste here. Database notifications are now not implicitly sent to the main thread, this means basic checks will happen in the thread pool beforehand. Bans without user ID are now sent to servers. Bans are rate limited to avoid undue work from mass ban imports, beyond the rate limit they are dropped. Improved error handling and logging for the whole system. Matching bans against connected players requires knowing their ban exemption flags. These are now cached when the player connects. ServerBanDef now has exemption flags, again to allow matching full ban details for ban notifications. Made database notifications a proper struct type to reduce copy pasting a tuple. Remove copy pasted connection string building code by just... passing the string into the constructor. Add lock around _notificationHandlers just in case. Fixed postgres connection wait not being called in a loop and therefore spamming LISTEN commands for every received notification. Added more error handling and logging to notification listener. Removed some copy pasting from SQLite database layer too while I was at it because god forbid we expect anybody else to do all the work in this project. Sorry Julian --------- Co-authored-by: Pieter-Jan Briers --- ...40606121555_ban_notify_trigger.Designer.cs | 1913 +++++++++++++++++ .../20240606121555_ban_notify_trigger.cs | 44 + Content.Server.Database/Model.cs | 7 +- .../Managers/BanManager.Notification.cs | 123 ++ .../Administration/Managers/BanManager.cs | 77 +- Content.Server/Database/BanMatcher.cs | 90 + Content.Server/Database/ServerBanDef.cs | 8 +- Content.Server/Database/ServerDbBase.cs | 25 +- Content.Server/Database/ServerDbManager.cs | 85 +- .../ServerDbPostgres.Notifications.cs | 121 ++ Content.Server/Database/ServerDbPostgres.cs | 19 +- Content.Server/Database/ServerDbSqlite.cs | 69 +- Resources/Locale/en-US/info/ban.ftl | 3 + 13 files changed, 2509 insertions(+), 75 deletions(-) create mode 100644 Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.Designer.cs create mode 100644 Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.cs create mode 100644 Content.Server/Administration/Managers/BanManager.Notification.cs create mode 100644 Content.Server/Database/BanMatcher.cs create mode 100644 Content.Server/Database/ServerDbPostgres.Notifications.cs diff --git a/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.Designer.cs b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.Designer.cs new file mode 100644 index 0000000000..2f06e5ff98 --- /dev/null +++ b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.Designer.cs @@ -0,0 +1,1913 @@ +// +using System; +using System.Net; +using System.Text.Json; +using Content.Server.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace Content.Server.Database.Migrations.Postgres +{ + [DbContext(typeof(PostgresServerDbContext))] + [Migration("20240606121555_ban_notify_trigger")] + partial class ban_notify_trigger + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("AdminRankId") + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + b.Property("Title") + .HasColumnType("text") + .HasColumnName("title"); + + b.HasKey("UserId") + .HasName("PK_admin"); + + b.HasIndex("AdminRankId") + .HasDatabaseName("IX_admin_admin_rank_id"); + + b.ToTable("admin", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_flag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminId") + .HasColumnType("uuid") + .HasColumnName("admin_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flag"); + + b.Property("Negative") + .HasColumnType("boolean") + .HasColumnName("negative"); + + b.HasKey("Id") + .HasName("PK_admin_flag"); + + b.HasIndex("AdminId") + .HasDatabaseName("IX_admin_flag_admin_id"); + + b.HasIndex("Flag", "AdminId") + .IsUnique(); + + b.ToTable("admin_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Id") + .HasColumnType("integer") + .HasColumnName("admin_log_id"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("Impact") + .HasColumnType("smallint") + .HasColumnName("impact"); + + b.Property("Json") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("json"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text") + .HasColumnName("message"); + + b.Property("Type") + .HasColumnType("integer") + .HasColumnName("type"); + + b.HasKey("RoundId", "Id") + .HasName("PK_admin_log"); + + b.HasIndex("Date"); + + b.HasIndex("Message") + .HasAnnotation("Npgsql:TsVectorConfig", "english"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Message"), "GIN"); + + b.HasIndex("Type") + .HasDatabaseName("IX_admin_log_type"); + + b.ToTable("admin_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("LogId") + .HasColumnType("integer") + .HasColumnName("log_id"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.HasKey("RoundId", "LogId", "PlayerUserId") + .HasName("PK_admin_log_player"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_log_player_player_user_id"); + + b.ToTable("admin_log_player", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_messages_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("Dismissed") + .HasColumnType("boolean") + .HasColumnName("dismissed"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Seen") + .HasColumnType("boolean") + .HasColumnName("seen"); + + b.HasKey("Id") + .HasName("PK_admin_messages"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_messages_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_messages_round_id"); + + b.ToTable("admin_messages", null, t => + { + t.HasCheckConstraint("NotDismissedAndSeen", "NOT dismissed OR seen"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_notes_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .IsRequired() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Secret") + .HasColumnType("boolean") + .HasColumnName("secret"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("PK_admin_notes"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_notes_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_notes_round_id"); + + b.ToTable("admin_notes", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_admin_rank"); + + b.ToTable("admin_rank", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_rank_flag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminRankId") + .HasColumnType("integer") + .HasColumnName("admin_rank_id"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flag"); + + b.HasKey("Id") + .HasName("PK_admin_rank_flag"); + + b.HasIndex("AdminRankId"); + + b.HasIndex("Flag", "AdminRankId") + .IsUnique(); + + b.ToTable("admin_rank_flag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("admin_watchlists_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("CreatedById") + .HasColumnType("uuid") + .HasColumnName("created_by_id"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedById") + .HasColumnType("uuid") + .HasColumnName("deleted_by_id"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("LastEditedAt") + .IsRequired() + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("message"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.HasKey("Id") + .HasName("PK_admin_watchlists"); + + b.HasIndex("CreatedById"); + + b.HasIndex("DeletedById"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_admin_watchlists_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_admin_watchlists_round_id"); + + b.ToTable("admin_watchlists", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("antag_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AntagName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("antag_name"); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_antag"); + + b.HasIndex("ProfileId", "AntagName") + .IsUnique(); + + b.ToTable("antag", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.AssignedUserId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("assigned_user_id_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_assigned_user_id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("assigned_user_id", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("connection_log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("Denied") + .HasColumnType("smallint") + .HasColumnName("denied"); + + b.Property("HWId") + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b.Property("ServerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0) + .HasColumnName("server_id"); + + b.Property("Time") + .HasColumnType("timestamp with time zone") + .HasColumnName("time"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_name"); + + b.HasKey("Id") + .HasName("PK_connection_log"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_connection_log_server_id"); + + b.HasIndex("UserId"); + + b.ToTable("connection_log", null, t => + { + t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("job_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("job_name"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.HasKey("Id") + .HasName("PK_job"); + + b.HasIndex("ProfileId"); + + b.HasIndex("ProfileId", "JobName") + .IsUnique(); + + b.HasIndex(new[] { "ProfileId" }, "IX_job_one_high_priority") + .IsUnique() + .HasFilter("priority = 3"); + + b.ToTable("job", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.PlayTime", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("play_time_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PlayerId") + .HasColumnType("uuid") + .HasColumnName("player_id"); + + b.Property("TimeSpent") + .HasColumnType("interval") + .HasColumnName("time_spent"); + + b.Property("Tracker") + .IsRequired() + .HasColumnType("text") + .HasColumnName("tracker"); + + b.HasKey("Id") + .HasName("PK_play_time"); + + b.HasIndex("PlayerId", "Tracker") + .IsUnique(); + + b.ToTable("play_time", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("player_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FirstSeenTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_time"); + + b.Property("LastReadRules") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_read_rules"); + + b.Property("LastSeenAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("last_seen_address"); + + b.Property("LastSeenHWId") + .HasColumnType("bytea") + .HasColumnName("last_seen_hwid"); + + b.Property("LastSeenTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_time"); + + b.Property("LastSeenUserName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("last_seen_user_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_player"); + + b.HasAlternateKey("UserId") + .HasName("ak_player_user_id"); + + b.HasIndex("LastSeenUserName"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("player", null, t => + { + t.HasCheckConstraint("LastSeenAddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= last_seen_address"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("preference_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdminOOCColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("admin_ooc_color"); + + b.Property("SelectedCharacterSlot") + .HasColumnType("integer") + .HasColumnName("selected_character_slot"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_preference"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("preference", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Age") + .HasColumnType("integer") + .HasColumnName("age"); + + b.Property("CharacterName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("char_name"); + + b.Property("EyeColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("eye_color"); + + b.Property("FacialHairColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("facial_hair_color"); + + b.Property("FacialHairName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("facial_hair_name"); + + b.Property("FlavorText") + .IsRequired() + .HasColumnType("text") + .HasColumnName("flavor_text"); + + b.Property("Gender") + .IsRequired() + .HasColumnType("text") + .HasColumnName("gender"); + + b.Property("HairColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hair_color"); + + b.Property("HairName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hair_name"); + + b.Property("Markings") + .HasColumnType("jsonb") + .HasColumnName("markings"); + + b.Property("PreferenceId") + .HasColumnType("integer") + .HasColumnName("preference_id"); + + b.Property("PreferenceUnavailable") + .HasColumnType("integer") + .HasColumnName("pref_unavailable"); + + b.Property("Sex") + .IsRequired() + .HasColumnType("text") + .HasColumnName("sex"); + + b.Property("SkinColor") + .IsRequired() + .HasColumnType("text") + .HasColumnName("skin_color"); + + b.Property("Slot") + .HasColumnType("integer") + .HasColumnName("slot"); + + b.Property("SpawnPriority") + .HasColumnType("integer") + .HasColumnName("spawn_priority"); + + b.Property("Species") + .IsRequired() + .HasColumnType("text") + .HasColumnName("species"); + + b.HasKey("Id") + .HasName("PK_profile"); + + b.HasIndex("PreferenceId") + .HasDatabaseName("IX_profile_preference_id"); + + b.HasIndex("Slot", "PreferenceId") + .IsUnique(); + + b.ToTable("profile", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_loadout_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LoadoutName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("loadout_name"); + + b.Property("ProfileLoadoutGroupId") + .HasColumnType("integer") + .HasColumnName("profile_loadout_group_id"); + + b.HasKey("Id") + .HasName("PK_profile_loadout"); + + b.HasIndex("ProfileLoadoutGroupId"); + + b.ToTable("profile_loadout", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_loadout_group_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GroupName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("group_name"); + + b.Property("ProfileRoleLoadoutId") + .HasColumnType("integer") + .HasColumnName("profile_role_loadout_id"); + + b.HasKey("Id") + .HasName("PK_profile_loadout_group"); + + b.HasIndex("ProfileRoleLoadoutId"); + + b.ToTable("profile_loadout_group", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("profile_role_loadout_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.Property("RoleName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_name"); + + b.HasKey("Id") + .HasName("PK_profile_role_loadout"); + + b.HasIndex("ProfileId"); + + b.ToTable("profile_role_loadout", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b => + { + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("RoleId") + .HasColumnType("text") + .HasColumnName("role_id"); + + b.HasKey("PlayerUserId", "RoleId") + .HasName("PK_role_whitelists"); + + b.ToTable("role_whitelists", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("round_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ServerId") + .HasColumnType("integer") + .HasColumnName("server_id"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_date"); + + b.HasKey("Id") + .HasName("PK_round"); + + b.HasIndex("ServerId") + .HasDatabaseName("IX_round_server_id"); + + b.HasIndex("StartDate"); + + b.ToTable("round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.HasKey("Id") + .HasName("PK_server"); + + b.ToTable("server", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_ban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("AutoDelete") + .HasColumnType("boolean") + .HasColumnName("auto_delete"); + + b.Property("BanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("uuid") + .HasColumnName("banning_admin"); + + b.Property("ExemptFlags") + .HasColumnType("integer") + .HasColumnName("exempt_flags"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("HWId") + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b.Property("Hidden") + .HasColumnType("boolean") + .HasColumnName("hidden"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("PK_server_ban"); + + b.HasIndex("Address"); + + b.HasIndex("BanningAdmin"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_server_ban_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_server_ban_round_id"); + + b.ToTable("server_ban", null, t => + { + t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + + t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanExemption", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Flags") + .HasColumnType("integer") + .HasColumnName("flags"); + + b.HasKey("UserId") + .HasName("PK_server_ban_exemption"); + + b.ToTable("server_ban_exemption", null, t => + { + t.HasCheckConstraint("FlagsNotZero", "flags != 0"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_ban_hit_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("ConnectionId") + .HasColumnType("integer") + .HasColumnName("connection_id"); + + b.HasKey("Id") + .HasName("PK_server_ban_hit"); + + b.HasIndex("BanId") + .HasDatabaseName("IX_server_ban_hit_ban_id"); + + b.HasIndex("ConnectionId") + .HasDatabaseName("IX_server_ban_hit_connection_id"); + + b.ToTable("server_ban_hit", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("server_role_ban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasColumnType("inet") + .HasColumnName("address"); + + b.Property("BanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("ban_time"); + + b.Property("BanningAdmin") + .HasColumnType("uuid") + .HasColumnName("banning_admin"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("expiration_time"); + + b.Property("HWId") + .HasColumnType("bytea") + .HasColumnName("hwid"); + + b.Property("Hidden") + .HasColumnType("boolean") + .HasColumnName("hidden"); + + b.Property("LastEditedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_edited_at"); + + b.Property("LastEditedById") + .HasColumnType("uuid") + .HasColumnName("last_edited_by_id"); + + b.Property("PlayerUserId") + .HasColumnType("uuid") + .HasColumnName("player_user_id"); + + b.Property("PlaytimeAtNote") + .HasColumnType("interval") + .HasColumnName("playtime_at_note"); + + b.Property("Reason") + .IsRequired() + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("role_id"); + + b.Property("RoundId") + .HasColumnType("integer") + .HasColumnName("round_id"); + + b.Property("Severity") + .HasColumnType("integer") + .HasColumnName("severity"); + + b.HasKey("Id") + .HasName("PK_server_role_ban"); + + b.HasIndex("Address"); + + b.HasIndex("BanningAdmin"); + + b.HasIndex("LastEditedById"); + + b.HasIndex("PlayerUserId") + .HasDatabaseName("IX_server_role_ban_player_user_id"); + + b.HasIndex("RoundId") + .HasDatabaseName("IX_server_role_ban_round_id"); + + b.ToTable("server_role_ban", null, t => + { + t.HasCheckConstraint("AddressNotIPv6MappedIPv4", "NOT inet '::ffff:0.0.0.0/96' >>= address"); + + t.HasCheckConstraint("HaveEitherAddressOrUserIdOrHWId", "address IS NOT NULL OR player_user_id IS NOT NULL OR hwid IS NOT NULL"); + }); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("role_unban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("uuid") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_server_role_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("server_role_unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.ServerUnban", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("unban_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BanId") + .HasColumnType("integer") + .HasColumnName("ban_id"); + + b.Property("UnbanTime") + .HasColumnType("timestamp with time zone") + .HasColumnName("unban_time"); + + b.Property("UnbanningAdmin") + .HasColumnType("uuid") + .HasColumnName("unbanning_admin"); + + b.HasKey("Id") + .HasName("PK_server_unban"); + + b.HasIndex("BanId") + .IsUnique(); + + b.ToTable("server_unban", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Trait", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("trait_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ProfileId") + .HasColumnType("integer") + .HasColumnName("profile_id"); + + b.Property("TraitName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("trait_name"); + + b.HasKey("Id") + .HasName("PK_trait"); + + b.HasIndex("ProfileId", "TraitName") + .IsUnique(); + + b.ToTable("trait", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.UploadedResourceLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("uploaded_resource_log_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("data"); + + b.Property("Date") + .HasColumnType("timestamp with time zone") + .HasColumnName("date"); + + b.Property("Path") + .IsRequired() + .HasColumnType("text") + .HasColumnName("path"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("PK_uploaded_resource_log"); + + b.ToTable("uploaded_resource_log", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Whitelist", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("UserId") + .HasName("PK_whitelist"); + + b.ToTable("whitelist", (string)null); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.Property("PlayersId") + .HasColumnType("integer") + .HasColumnName("players_id"); + + b.Property("RoundsId") + .HasColumnType("integer") + .HasColumnName("rounds_id"); + + b.HasKey("PlayersId", "RoundsId") + .HasName("PK_player_round"); + + b.HasIndex("RoundsId") + .HasDatabaseName("IX_player_round_rounds_id"); + + b.ToTable("player_round", (string)null); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.HasOne("Content.Server.Database.AdminRank", "AdminRank") + .WithMany("Admins") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_admin_rank_admin_rank_id"); + + b.Navigation("AdminRank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminFlag", b => + { + b.HasOne("Content.Server.Database.Admin", "Admin") + .WithMany("Flags") + .HasForeignKey("AdminId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_flag_admin_admin_id"); + + b.Navigation("Admin"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany("AdminLogs") + .HasForeignKey("RoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_round_round_id"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLogPlayer", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminLogs") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_player_player_user_id"); + + b.HasOne("Content.Server.Database.AdminLog", "Log") + .WithMany("Players") + .HasForeignKey("RoundId", "LogId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_log_player_admin_log_round_id_log_id"); + + b.Navigation("Log"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminMessage", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminMessagesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminMessagesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminMessagesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_messages_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminMessagesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_messages_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_messages_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminNote", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminNotesCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminNotesDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminNotesLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_notes_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminNotesReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_notes_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_notes_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRankFlag", b => + { + b.HasOne("Content.Server.Database.AdminRank", "Rank") + .WithMany("Flags") + .HasForeignKey("AdminRankId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_admin_rank_flag_admin_rank_admin_rank_id"); + + b.Navigation("Rank"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminWatchlist", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminWatchlistsCreated") + .HasForeignKey("CreatedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_created_by_id"); + + b.HasOne("Content.Server.Database.Player", "DeletedBy") + .WithMany("AdminWatchlistsDeleted") + .HasForeignKey("DeletedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_deleted_by_id"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminWatchlistsLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_admin_watchlists_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("AdminWatchlistsReceived") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_admin_watchlists_player_player_user_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_admin_watchlists_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("DeletedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Player"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.Antag", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Antags") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_antag_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("ConnectionLogs") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired() + .HasConstraintName("FK_connection_log_server_server_id"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.Job", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Jobs") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_job_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.HasOne("Content.Server.Database.Preference", "Preference") + .WithMany("Profiles") + .HasForeignKey("PreferenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_preference_preference_id"); + + b.Navigation("Preference"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadout", b => + { + b.HasOne("Content.Server.Database.ProfileLoadoutGroup", "ProfileLoadoutGroup") + .WithMany("Loadouts") + .HasForeignKey("ProfileLoadoutGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_loadout_profile_loadout_group_profile_loadout_group~"); + + b.Navigation("ProfileLoadoutGroup"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.HasOne("Content.Server.Database.ProfileRoleLoadout", "ProfileRoleLoadout") + .WithMany("Groups") + .HasForeignKey("ProfileRoleLoadoutId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_loadout_group_profile_role_loadout_profile_role_loa~"); + + b.Navigation("ProfileRoleLoadout"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Loadouts") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_profile_role_loadout_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("Content.Server.Database.RoleWhitelist", b => + { + b.HasOne("Content.Server.Database.Player", "Player") + .WithMany("JobWhitelists") + .HasForeignKey("PlayerUserId") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_role_whitelists_player_player_user_id"); + + b.Navigation("Player"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.HasOne("Content.Server.Database.Server", "Server") + .WithMany("Rounds") + .HasForeignKey("ServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_round_server_server_id"); + + b.Navigation("Server"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBan", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminServerBansCreated") + .HasForeignKey("BanningAdmin") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_server_ban_player_banning_admin"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminServerBansLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_server_ban_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_server_ban_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBanHit", b => + { + b.HasOne("Content.Server.Database.ServerBan", "Ban") + .WithMany("BanHits") + .HasForeignKey("BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_server_ban_ban_id"); + + b.HasOne("Content.Server.Database.ConnectionLog", "Connection") + .WithMany("BanHits") + .HasForeignKey("ConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_ban_hit_connection_log_connection_id"); + + b.Navigation("Ban"); + + b.Navigation("Connection"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => + { + b.HasOne("Content.Server.Database.Player", "CreatedBy") + .WithMany("AdminServerRoleBansCreated") + .HasForeignKey("BanningAdmin") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_server_role_ban_player_banning_admin"); + + b.HasOne("Content.Server.Database.Player", "LastEditedBy") + .WithMany("AdminServerRoleBansLastEdited") + .HasForeignKey("LastEditedById") + .HasPrincipalKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_server_role_ban_player_last_edited_by_id"); + + b.HasOne("Content.Server.Database.Round", "Round") + .WithMany() + .HasForeignKey("RoundId") + .HasConstraintName("FK_server_role_ban_round_round_id"); + + b.Navigation("CreatedBy"); + + b.Navigation("LastEditedBy"); + + b.Navigation("Round"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleUnban", b => + { + b.HasOne("Content.Server.Database.ServerRoleBan", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.ServerRoleUnban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_role_unban_server_role_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerUnban", b => + { + b.HasOne("Content.Server.Database.ServerBan", "Ban") + .WithOne("Unban") + .HasForeignKey("Content.Server.Database.ServerUnban", "BanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_server_unban_server_ban_ban_id"); + + b.Navigation("Ban"); + }); + + modelBuilder.Entity("Content.Server.Database.Trait", b => + { + b.HasOne("Content.Server.Database.Profile", "Profile") + .WithMany("Traits") + .HasForeignKey("ProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_trait_profile_profile_id"); + + b.Navigation("Profile"); + }); + + modelBuilder.Entity("PlayerRound", b => + { + b.HasOne("Content.Server.Database.Player", null) + .WithMany() + .HasForeignKey("PlayersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_player_players_id"); + + b.HasOne("Content.Server.Database.Round", null) + .WithMany() + .HasForeignKey("RoundsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_player_round_round_rounds_id"); + }); + + modelBuilder.Entity("Content.Server.Database.Admin", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminLog", b => + { + b.Navigation("Players"); + }); + + modelBuilder.Entity("Content.Server.Database.AdminRank", b => + { + b.Navigation("Admins"); + + b.Navigation("Flags"); + }); + + modelBuilder.Entity("Content.Server.Database.ConnectionLog", b => + { + b.Navigation("BanHits"); + }); + + modelBuilder.Entity("Content.Server.Database.Player", b => + { + b.Navigation("AdminLogs"); + + b.Navigation("AdminMessagesCreated"); + + b.Navigation("AdminMessagesDeleted"); + + b.Navigation("AdminMessagesLastEdited"); + + b.Navigation("AdminMessagesReceived"); + + b.Navigation("AdminNotesCreated"); + + b.Navigation("AdminNotesDeleted"); + + b.Navigation("AdminNotesLastEdited"); + + b.Navigation("AdminNotesReceived"); + + b.Navigation("AdminServerBansCreated"); + + b.Navigation("AdminServerBansLastEdited"); + + b.Navigation("AdminServerRoleBansCreated"); + + b.Navigation("AdminServerRoleBansLastEdited"); + + b.Navigation("AdminWatchlistsCreated"); + + b.Navigation("AdminWatchlistsDeleted"); + + b.Navigation("AdminWatchlistsLastEdited"); + + b.Navigation("AdminWatchlistsReceived"); + + b.Navigation("JobWhitelists"); + }); + + modelBuilder.Entity("Content.Server.Database.Preference", b => + { + b.Navigation("Profiles"); + }); + + modelBuilder.Entity("Content.Server.Database.Profile", b => + { + b.Navigation("Antags"); + + b.Navigation("Jobs"); + + b.Navigation("Loadouts"); + + b.Navigation("Traits"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileLoadoutGroup", b => + { + b.Navigation("Loadouts"); + }); + + modelBuilder.Entity("Content.Server.Database.ProfileRoleLoadout", b => + { + b.Navigation("Groups"); + }); + + modelBuilder.Entity("Content.Server.Database.Round", b => + { + b.Navigation("AdminLogs"); + }); + + modelBuilder.Entity("Content.Server.Database.Server", b => + { + b.Navigation("ConnectionLogs"); + + b.Navigation("Rounds"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerBan", b => + { + b.Navigation("BanHits"); + + b.Navigation("Unban"); + }); + + modelBuilder.Entity("Content.Server.Database.ServerRoleBan", b => + { + b.Navigation("Unban"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.cs b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.cs new file mode 100644 index 0000000000..b84d230b45 --- /dev/null +++ b/Content.Server.Database/Migrations/Postgres/20240606121555_ban_notify_trigger.cs @@ -0,0 +1,44 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Content.Server.Database.Migrations.Postgres +{ + /// + public partial class ban_notify_trigger : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + create or replace function send_server_ban_notification() + returns trigger as $$ + declare + x_server_id integer; + begin + select round.server_id into x_server_id from round where round.round_id = NEW.round_id; + + perform pg_notify('ban_notification', json_build_object('ban_id', NEW.server_ban_id, 'server_id', x_server_id)::text); + return NEW; + end; + $$ LANGUAGE plpgsql; + """); + + migrationBuilder.Sql(""" + create or replace trigger notify_on_server_ban_insert + after insert on server_ban + for each row + execute function send_server_ban_notification(); + """); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + drop trigger notify_on_server_ban_insert on server_ban; + drop function send_server_ban_notification; + """); + } + } +} diff --git a/Content.Server.Database/Model.cs b/Content.Server.Database/Model.cs index d195201c29..ea63c41fc2 100644 --- a/Content.Server.Database/Model.cs +++ b/Content.Server.Database/Model.cs @@ -705,6 +705,11 @@ namespace Content.Server.Database /// Intended for use with residential IP ranges that are often used maliciously. /// BlacklistedRange = 1 << 2, + + /// + /// Represents having all possible exemption flags. + /// + All = int.MaxValue, // @formatter:on } @@ -903,7 +908,7 @@ namespace Content.Server.Database Panic = 3, /* * TODO: Remove baby jail code once a more mature gateway process is established. This code is only being issued as a stopgap to help with potential tiding in the immediate future. - * + * * If baby jail is removed, please reserve this value for as long as can reasonably be done to prevent causing ambiguity in connection denial reasons. * Reservation by commenting out the value is likely sufficient for this purpose, but may impact projects which depend on SS14 like SS14.Admin. */ diff --git a/Content.Server/Administration/Managers/BanManager.Notification.cs b/Content.Server/Administration/Managers/BanManager.Notification.cs new file mode 100644 index 0000000000..e9bfa62884 --- /dev/null +++ b/Content.Server/Administration/Managers/BanManager.Notification.cs @@ -0,0 +1,123 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Content.Server.Database; + +namespace Content.Server.Administration.Managers; + +public sealed partial class BanManager +{ + // Responsible for ban notification handling. + // Ban notifications are sent through the database to notify the entire server group that a new ban has been added, + // so that people will get kicked if they are banned on a different server than the one that placed the ban. + // + // Ban notifications are currently sent by a trigger in the database, automatically. + + /// + /// The notification channel used to broadcast information about new bans. + /// + public const string BanNotificationChannel = "ban_notification"; + + // Rate limit to avoid undue load from mass-ban imports. + // Only process 10 bans per 30 second interval. + // + // I had the idea of maybe binning this by postgres transaction ID, + // to avoid any possibility of dropping a normal ban by coincidence. + // Didn't bother implementing this though. + private static readonly TimeSpan BanNotificationRateLimitTime = TimeSpan.FromSeconds(30); + private const int BanNotificationRateLimitCount = 10; + + private readonly object _banNotificationRateLimitStateLock = new(); + private TimeSpan _banNotificationRateLimitStart; + private int _banNotificationRateLimitCount; + + private void OnDatabaseNotification(DatabaseNotification notification) + { + if (notification.Channel != BanNotificationChannel) + return; + + if (notification.Payload == null) + { + _sawmill.Error("Got ban notification with null payload!"); + return; + } + + BanNotificationData data; + try + { + data = JsonSerializer.Deserialize(notification.Payload) + ?? throw new JsonException("Content is null"); + } + catch (JsonException e) + { + _sawmill.Error($"Got invalid JSON in ban notification: {e}"); + return; + } + + if (!CheckBanRateLimit()) + { + _sawmill.Verbose("Not processing ban notification due to rate limit"); + return; + } + + _taskManager.RunOnMainThread(() => ProcessBanNotification(data)); + } + + private async void ProcessBanNotification(BanNotificationData data) + { + if ((await _entryManager.ServerEntity).Id == data.ServerId) + { + _sawmill.Verbose("Not processing ban notification: came from this server"); + return; + } + + _sawmill.Verbose($"Processing ban notification for ban {data.BanId}"); + var ban = await _db.GetServerBanAsync(data.BanId); + if (ban == null) + { + _sawmill.Warning($"Ban in notification ({data.BanId}) didn't exist?"); + return; + } + + KickMatchingConnectedPlayers(ban, "ban notification"); + } + + private bool CheckBanRateLimit() + { + lock (_banNotificationRateLimitStateLock) + { + var now = _gameTiming.RealTime; + if (_banNotificationRateLimitStart + BanNotificationRateLimitTime < now) + { + // Rate limit period expired, restart it. + _banNotificationRateLimitCount = 1; + _banNotificationRateLimitStart = now; + return true; + } + + _banNotificationRateLimitCount += 1; + return _banNotificationRateLimitCount <= BanNotificationRateLimitCount; + } + } + + /// + /// Data sent along the notification channel for a single ban notification. + /// + private sealed class BanNotificationData + { + /// + /// The ID of the new ban object in the database to check. + /// + [JsonRequired, JsonPropertyName("ban_id")] + public int BanId { get; init; } + + /// + /// The id of the server the ban was made on. + /// This is used to avoid double work checking the ban on the originating server. + /// + /// + /// This is optional in case the ban was made outside a server (SS14.Admin) + /// + [JsonPropertyName("server_id")] + public int? ServerId { get; init; } + } +} diff --git a/Content.Server/Administration/Managers/BanManager.cs b/Content.Server/Administration/Managers/BanManager.cs index 68bd817026..946770d6aa 100644 --- a/Content.Server/Administration/Managers/BanManager.cs +++ b/Content.Server/Administration/Managers/BanManager.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Linq; using System.Net; using System.Text; +using System.Threading; using System.Threading.Tasks; using Content.Server.Chat.Managers; using Content.Server.Database; @@ -12,16 +13,18 @@ using Content.Shared.Players; using Content.Shared.Players.PlayTimeTracking; using Content.Shared.Roles; using Robust.Server.Player; +using Robust.Shared.Asynchronous; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; +using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Server.Administration.Managers; -public sealed class BanManager : IBanManager, IPostInjectInit +public sealed partial class BanManager : IBanManager, IPostInjectInit { [Dependency] private readonly IServerDbManager _db = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; @@ -29,9 +32,13 @@ public sealed class BanManager : IBanManager, IPostInjectInit [Dependency] private readonly IEntitySystemManager _systems = default!; [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly ILocalizationManager _localizationManager = default!; + [Dependency] private readonly ServerDbEntryManager _entryManager = default!; [Dependency] private readonly IChatManager _chat = default!; [Dependency] private readonly INetManager _netManager = default!; [Dependency] private readonly ILogManager _logManager = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly ITaskManager _taskManager = default!; + [Dependency] private readonly UserDbDataManager _userDbData = default!; private ISawmill _sawmill = default!; @@ -39,12 +46,34 @@ public sealed class BanManager : IBanManager, IPostInjectInit public const string JobPrefix = "Job:"; private readonly Dictionary> _cachedRoleBans = new(); + // Cached ban exemption flags are used to handle + private readonly Dictionary _cachedBanExemptions = new(); public void Initialize() { _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; _netManager.RegisterNetMessage(); + + _db.SubscribeToNotifications(OnDatabaseNotification); + + _userDbData.AddOnLoadPlayer(CachePlayerData); + _userDbData.AddOnPlayerDisconnect(ClearPlayerData); + } + + private async Task CachePlayerData(ICommonSession player, CancellationToken cancel) + { + // Yeah so role ban loading code isn't integrated with exempt flag loading code. + // Have you seen how garbage role ban code code is? I don't feel like refactoring it right now. + + var flags = await _db.GetBanExemption(player.UserId, cancel); + cancel.ThrowIfCancellationRequested(); + _cachedBanExemptions[player] = flags; + } + + private void ClearPlayerData(ICommonSession player) + { + _cachedBanExemptions.Remove(player); } private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) @@ -168,17 +197,43 @@ public sealed class BanManager : IBanManager, IPostInjectInit _sawmill.Info(logMessage); _chat.SendAdminAlert(logMessage); - // If we're not banning a player we don't care about disconnecting people - if (target == null) - return; - - // Is the player connected? - if (!_playerManager.TryGetSessionById(target.Value, out var targetPlayer)) - return; - // If they are, kick them - var message = banDef.FormatBanMessage(_cfg, _localizationManager); - targetPlayer.Channel.Disconnect(message); + KickMatchingConnectedPlayers(banDef, "newly placed ban"); } + + private void KickMatchingConnectedPlayers(ServerBanDef def, string source) + { + foreach (var player in _playerManager.Sessions) + { + if (BanMatchesPlayer(player, def)) + { + KickForBanDef(player, def); + _sawmill.Info($"Kicked player {player.Name} ({player.UserId}) through {source}"); + } + } + } + + private bool BanMatchesPlayer(ICommonSession player, ServerBanDef ban) + { + var playerInfo = new BanMatcher.PlayerInfo + { + UserId = player.UserId, + Address = player.Channel.RemoteEndPoint.Address, + HWId = player.Channel.UserData.HWId, + // It's possible for the player to not have cached data loading yet due to coincidental timing. + // If this is the case, we assume they have all flags to avoid false-positives. + ExemptFlags = _cachedBanExemptions.GetValueOrDefault(player, ServerBanExemptFlags.All), + IsNewPlayer = false, + }; + + return BanMatcher.BanMatches(ban, playerInfo); + } + + private void KickForBanDef(ICommonSession player, ServerBanDef def) + { + var message = def.FormatBanMessage(_cfg, _localizationManager); + player.Channel.Disconnect(message); + } + #endregion #region Job Bans diff --git a/Content.Server/Database/BanMatcher.cs b/Content.Server/Database/BanMatcher.cs new file mode 100644 index 0000000000..e58e5b0b5f --- /dev/null +++ b/Content.Server/Database/BanMatcher.cs @@ -0,0 +1,90 @@ +using System.Collections.Immutable; +using System.Net; +using Content.Server.IP; +using Robust.Shared.Network; + +namespace Content.Server.Database; + +/// +/// Implements logic to match a against a player query. +/// +/// +/// +/// This implementation is used by in-game ban matching code, and partially by the SQLite database layer. +/// Some logic is duplicated into both the SQLite and PostgreSQL database layers to provide more optimal SQL queries. +/// Both should be kept in sync, please! +/// +/// +public static class BanMatcher +{ + /// + /// Check whether a ban matches the specified player info. + /// + /// + /// + /// This function does not check whether the ban itself is expired or manually unbanned. + /// + /// + /// The ban information. + /// Information about the player to match against. + /// True if the ban matches the provided player info. + public static bool BanMatches(ServerBanDef ban, in PlayerInfo player) + { + var exemptFlags = player.ExemptFlags; + // Any flag to bypass BlacklistedRange bans. + if (exemptFlags != ServerBanExemptFlags.None) + exemptFlags |= ServerBanExemptFlags.BlacklistedRange; + + if ((ban.ExemptFlags & exemptFlags) != 0) + return false; + + if (!player.ExemptFlags.HasFlag(ServerBanExemptFlags.IP) + && player.Address != null + && ban.Address is not null + && player.Address.IsInSubnet(ban.Address.Value) + && (!ban.ExemptFlags.HasFlag(ServerBanExemptFlags.BlacklistedRange) || player.IsNewPlayer)) + { + return true; + } + + if (player.UserId is { } id && ban.UserId == id.UserId) + { + return true; + } + + return player.HWId is { Length: > 0 } hwIdVar + && ban.HWId != null + && hwIdVar.AsSpan().SequenceEqual(ban.HWId.Value.AsSpan()); + } + + /// + /// A simple struct containing player info used to match bans against. + /// + public struct PlayerInfo + { + /// + /// The user ID of the player. + /// + public NetUserId? UserId; + + /// + /// The IP address of the player. + /// + public IPAddress? Address; + + /// + /// The hardware ID of the player. + /// + public ImmutableArray? HWId; + + /// + /// Exemption flags the player has been granted. + /// + public ServerBanExemptFlags ExemptFlags; + + /// + /// True if this player is new and is thus eligible for more bans. + /// + public bool IsNewPlayer; + } +} diff --git a/Content.Server/Database/ServerBanDef.cs b/Content.Server/Database/ServerBanDef.cs index 9d67537bd2..09a960e9a6 100644 --- a/Content.Server/Database/ServerBanDef.cs +++ b/Content.Server/Database/ServerBanDef.cs @@ -23,9 +23,9 @@ namespace Content.Server.Database public NoteSeverity Severity { get; set; } public NetUserId? BanningAdmin { get; } public ServerUnbanDef? Unban { get; } + public ServerBanExemptFlags ExemptFlags { get; } - public ServerBanDef( - int? id, + public ServerBanDef(int? id, NetUserId? userId, (IPAddress, int)? address, ImmutableArray? hwId, @@ -36,7 +36,8 @@ namespace Content.Server.Database string reason, NoteSeverity severity, NetUserId? banningAdmin, - ServerUnbanDef? unban) + ServerUnbanDef? unban, + ServerBanExemptFlags exemptFlags = default) { if (userId == null && address == null && hwId == null) { @@ -62,6 +63,7 @@ namespace Content.Server.Database Severity = severity; BanningAdmin = banningAdmin; Unban = unban; + ExemptFlags = exemptFlags; } public string FormatBanMessage(IConfigurationManager cfg, ILocalizationManager loc) diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs index d91ef39198..72e3c50daf 100644 --- a/Content.Server/Database/ServerDbBase.cs +++ b/Content.Server/Database/ServerDbBase.cs @@ -28,6 +28,8 @@ namespace Content.Server.Database { private readonly ISawmill _opsLog; + public event Action? OnNotificationReceived; + /// Sawmill to trace log database operations to. public ServerDbBase(ISawmill opsLog) { @@ -425,13 +427,16 @@ namespace Content.Server.Database await db.DbContext.SaveChangesAsync(); } - protected static async Task GetBanExemptionCore(DbGuard db, NetUserId? userId) + protected static async Task GetBanExemptionCore( + DbGuard db, + NetUserId? userId, + CancellationToken cancel = default) { if (userId == null) return null; var exemption = await db.DbContext.BanExemption - .SingleOrDefaultAsync(e => e.UserId == userId.Value.UserId); + .SingleOrDefaultAsync(e => e.UserId == userId.Value.UserId, cancellationToken: cancel); return exemption?.Flags; } @@ -462,11 +467,11 @@ namespace Content.Server.Database await db.DbContext.SaveChangesAsync(); } - public async Task GetBanExemption(NetUserId userId) + public async Task GetBanExemption(NetUserId userId, CancellationToken cancel) { - await using var db = await GetDb(); + await using var db = await GetDb(cancel); - var flags = await GetBanExemptionCore(db, userId); + var flags = await GetBanExemptionCore(db, userId, cancel); return flags ?? ServerBanExemptFlags.None; } @@ -1677,5 +1682,15 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} public abstract ValueTask DisposeAsync(); } + + protected void NotificationReceived(DatabaseNotification notification) + { + OnNotificationReceived?.Invoke(notification); + } + + public virtual void Shutdown() + { + + } } } diff --git a/Content.Server/Database/ServerDbManager.cs b/Content.Server/Database/ServerDbManager.cs index 1983fe43d2..8b6ac5fed6 100644 --- a/Content.Server/Database/ServerDbManager.cs +++ b/Content.Server/Database/ServerDbManager.cs @@ -116,7 +116,7 @@ namespace Content.Server.Database /// Get current ban exemption flags for a user /// /// if the user is not exempt from any bans. - Task GetBanExemption(NetUserId userId); + Task GetBanExemption(NetUserId userId, CancellationToken cancel = default); #endregion @@ -304,6 +304,43 @@ namespace Content.Server.Database Task RemoveJobWhitelist(Guid player, ProtoId job); #endregion + + #region DB Notifications + + void SubscribeToNotifications(Action handler); + + /// + /// Inject a notification as if it was created by the database. This is intended for testing. + /// + /// The notification to trigger + void InjectTestNotification(DatabaseNotification notification); + + #endregion + } + + /// + /// Represents a notification sent between servers via the database layer. + /// + /// + /// + /// Database notifications are a simple system to broadcast messages to an entire server group + /// backed by the same database. For example, this is used to notify all servers of new ban records. + /// + /// + /// They are currently implemented by the PostgreSQL NOTIFY and LISTEN commands. + /// + /// + public struct DatabaseNotification + { + /// + /// The channel for the notification. This can be used to differentiate notifications for different purposes. + /// + public required string Channel { get; set; } + + /// + /// The actual contents of the notification. Optional. + /// + public string? Payload { get; set; } } public sealed class ServerDbManager : IServerDbManager @@ -333,6 +370,8 @@ namespace Content.Server.Database // This is that connection, close it when we shut down. private SqliteConnection? _sqliteInMemoryConnection; + private readonly List> _notificationHandlers = []; + public void Init() { _msLogProvider = new LoggingProvider(_logMgr); @@ -345,6 +384,7 @@ namespace Content.Server.Database var engine = _cfg.GetCVar(CCVars.DatabaseEngine).ToLower(); var opsLog = _logMgr.GetSawmill("db.op"); + var notifyLog = _logMgr.GetSawmill("db.notify"); switch (engine) { case "sqlite": @@ -352,17 +392,22 @@ namespace Content.Server.Database _db = new ServerDbSqlite(contextFunc, inMemory, _cfg, _synchronous, opsLog); break; case "postgres": - var pgOptions = CreatePostgresOptions(); - _db = new ServerDbPostgres(pgOptions, _cfg, opsLog); + var (pgOptions, conString) = CreatePostgresOptions(); + _db = new ServerDbPostgres(pgOptions, conString, _cfg, opsLog, notifyLog); break; default: throw new InvalidDataException($"Unknown database engine {engine}."); } + + _db.OnNotificationReceived += HandleDatabaseNotification; } public void Shutdown() { + _db.OnNotificationReceived -= HandleDatabaseNotification; + _sqliteInMemoryConnection?.Dispose(); + _db.Shutdown(); } public Task InitPrefsAsync( @@ -465,10 +510,10 @@ namespace Content.Server.Database return RunDbCommand(() => _db.UpdateBanExemption(userId, flags)); } - public Task GetBanExemption(NetUserId userId) + public Task GetBanExemption(NetUserId userId, CancellationToken cancel = default) { DbReadOpsMetric.Inc(); - return RunDbCommand(() => _db.GetBanExemption(userId)); + return RunDbCommand(() => _db.GetBanExemption(userId, cancel)); } #region Role Ban @@ -806,7 +851,7 @@ namespace Content.Server.Database return RunDbCommand(() => _db.GetServerRoleBanAsNoteAsync(id)); } - public Task> GetAllAdminRemarks(Guid player) + public Task> GetAllAdminRemarks(Guid player) { DbReadOpsMetric.Inc(); return RunDbCommand(() => _db.GetAllAdminRemarks(player)); @@ -907,6 +952,30 @@ namespace Content.Server.Database return RunDbCommand(() => _db.RemoveJobWhitelist(player, job)); } + public void SubscribeToNotifications(Action handler) + { + lock (_notificationHandlers) + { + _notificationHandlers.Add(handler); + } + } + + public void InjectTestNotification(DatabaseNotification notification) + { + HandleDatabaseNotification(notification); + } + + private async void HandleDatabaseNotification(DatabaseNotification notification) + { + lock (_notificationHandlers) + { + foreach (var handler in _notificationHandlers) + { + handler(notification); + } + } + } + // Wrapper functions to run DB commands from the thread pool. // This will avoid SynchronizationContext capturing and avoid running CPU work on the main thread. // For SQLite, this will also enable read parallelization (within limits). @@ -962,7 +1031,7 @@ namespace Content.Server.Database return enumerable; } - private DbContextOptions CreatePostgresOptions() + private (DbContextOptions options, string connectionString) CreatePostgresOptions() { var host = _cfg.GetCVar(CCVars.DatabasePgHost); var port = _cfg.GetCVar(CCVars.DatabasePgPort); @@ -984,7 +1053,7 @@ namespace Content.Server.Database builder.UseNpgsql(connectionString); SetupLogging(builder); - return builder.Options; + return (builder.Options, connectionString); } private void SetupSqlite(out Func> contextFunc, out bool inMemory) diff --git a/Content.Server/Database/ServerDbPostgres.Notifications.cs b/Content.Server/Database/ServerDbPostgres.Notifications.cs new file mode 100644 index 0000000000..fe358923bf --- /dev/null +++ b/Content.Server/Database/ServerDbPostgres.Notifications.cs @@ -0,0 +1,121 @@ +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using Content.Server.Administration.Managers; +using Npgsql; + +namespace Content.Server.Database; + +/// Listens for ban_notification containing the player id and the banning server id using postgres listen/notify. +/// Players a ban_notification got received for get banned, except when the current server id and the one in the notification payload match. + +public sealed partial class ServerDbPostgres +{ + /// + /// The list of notify channels to subscribe to. + /// + private static readonly string[] NotificationChannels = + [ + BanManager.BanNotificationChannel, + ]; + + private static readonly TimeSpan ReconnectWaitIncrease = TimeSpan.FromSeconds(10); + + private readonly CancellationTokenSource _notificationTokenSource = new(); + + private NpgsqlConnection? _notificationConnection; + private TimeSpan _reconnectWaitTime = TimeSpan.Zero; + + /// + /// Sets up the database connection and the notification handler + /// + private void InitNotificationListener(string connectionString) + { + _notificationConnection = new NpgsqlConnection(connectionString); + _notificationConnection.Notification += OnNotification; + + var cancellationToken = _notificationTokenSource.Token; + Task.Run(() => NotificationListener(cancellationToken), cancellationToken); + } + + /// + /// Listens to the notification channel with basic error handling and reopens the connection if it got closed + /// + private async Task NotificationListener(CancellationToken cancellationToken) + { + if (_notificationConnection == null) + return; + + _notifyLog.Verbose("Starting notification listener"); + + while (!cancellationToken.IsCancellationRequested) + { + try + { + if (_notificationConnection.State == ConnectionState.Broken) + { + _notifyLog.Debug("Notification listener entered broken state, closing..."); + await _notificationConnection.CloseAsync(); + } + + if (_notificationConnection.State == ConnectionState.Closed) + { + _notifyLog.Debug("Opening notification listener connection..."); + if (_reconnectWaitTime != TimeSpan.Zero) + { + _notifyLog.Verbose($"_reconnectWaitTime is {_reconnectWaitTime}"); + await Task.Delay(_reconnectWaitTime, cancellationToken); + } + + await _notificationConnection.OpenAsync(cancellationToken); + _reconnectWaitTime = TimeSpan.Zero; + _notifyLog.Verbose($"Notification connection opened..."); + } + + foreach (var channel in NotificationChannels) + { + _notifyLog.Verbose($"Listening on channel {channel}"); + await using var cmd = new NpgsqlCommand($"LISTEN {channel}", _notificationConnection); + await cmd.ExecuteNonQueryAsync(cancellationToken); + } + + while (!cancellationToken.IsCancellationRequested) + { + _notifyLog.Verbose("Waiting on notifications..."); + await _notificationConnection.WaitAsync(cancellationToken); + } + } + catch (OperationCanceledException) + { + // Abort loop on cancel. + _notifyLog.Verbose($"Shutting down notification listener due to cancellation"); + return; + } + catch (Exception e) + { + _reconnectWaitTime += ReconnectWaitIncrease; + _notifyLog.Error($"Error in notification listener: {e}"); + } + } + } + + private void OnNotification(object _, NpgsqlNotificationEventArgs notification) + { + _notifyLog.Verbose($"Received notification on channel {notification.Channel}"); + NotificationReceived(new DatabaseNotification + { + Channel = notification.Channel, + Payload = notification.Payload, + }); + } + + public override void Shutdown() + { + _notificationTokenSource.Cancel(); + if (_notificationConnection == null) + return; + + _notificationConnection.Notification -= OnNotification; + _notificationConnection.Dispose(); + } +} diff --git a/Content.Server/Database/ServerDbPostgres.cs b/Content.Server/Database/ServerDbPostgres.cs index f8eef1a554..7d131f70dc 100644 --- a/Content.Server/Database/ServerDbPostgres.cs +++ b/Content.Server/Database/ServerDbPostgres.cs @@ -16,23 +16,26 @@ using Robust.Shared.Utility; namespace Content.Server.Database { - public sealed class ServerDbPostgres : ServerDbBase + public sealed partial class ServerDbPostgres : ServerDbBase { private readonly DbContextOptions _options; + private readonly ISawmill _notifyLog; private readonly SemaphoreSlim _prefsSemaphore; private readonly Task _dbReadyTask; private int _msLag; - public ServerDbPostgres( - DbContextOptions options, + public ServerDbPostgres(DbContextOptions options, + string connectionString, IConfigurationManager cfg, - ISawmill opsLog) + ISawmill opsLog, + ISawmill notifyLog) : base(opsLog) { var concurrency = cfg.GetCVar(CCVars.DatabasePgConcurrency); _options = options; + _notifyLog = notifyLog; _prefsSemaphore = new SemaphoreSlim(concurrency, concurrency); _dbReadyTask = Task.Run(async () => @@ -49,6 +52,8 @@ namespace Content.Server.Database }); cfg.OnValueChanged(CCVars.DatabasePgFakeLag, v => _msLag = v, true); + + InitNotificationListener(connectionString); } #region Ban @@ -214,7 +219,8 @@ namespace Content.Server.Database ban.Reason, ban.Severity, aUid, - unbanDef); + unbanDef, + ban.ExemptFlags); } private static ServerUnbanDef? ConvertUnban(ServerUnban? unban) @@ -251,7 +257,8 @@ namespace Content.Server.Database ExpirationTime = serverBan.ExpirationTime?.UtcDateTime, RoundId = serverBan.RoundId, PlaytimeAtNote = serverBan.PlaytimeAtNote, - PlayerUserId = serverBan.UserId?.UserId + PlayerUserId = serverBan.UserId?.UserId, + ExemptFlags = serverBan.ExemptFlags }); await db.PgDbContext.SaveChangesAsync(); diff --git a/Content.Server/Database/ServerDbSqlite.cs b/Content.Server/Database/ServerDbSqlite.cs index 204d9fca4f..af4bc2cf8d 100644 --- a/Content.Server/Database/ServerDbSqlite.cs +++ b/Content.Server/Database/ServerDbSqlite.cs @@ -84,25 +84,27 @@ namespace Content.Server.Database { await using var db = await GetDbImpl(); - var exempt = await GetBanExemptionCore(db, userId); - - var newPlayer = userId == null || !await PlayerRecordExists(db, userId.Value); - - // SQLite can't do the net masking stuff we need to match IP address ranges. - // So just pull down the whole list into memory. - var bans = await GetAllBans(db.SqliteDbContext, includeUnbanned: false, exempt); - - return bans.FirstOrDefault(b => BanMatches(b, address, userId, hwId, exempt, newPlayer)) is { } foundBan - ? ConvertBan(foundBan) - : null; + return (await GetServerBanQueryAsync(db, address, userId, hwId, includeUnbanned: false)).FirstOrDefault(); } - public override async Task> GetServerBansAsync(IPAddress? address, + public override async Task> GetServerBansAsync( + IPAddress? address, NetUserId? userId, - ImmutableArray? hwId, bool includeUnbanned) + ImmutableArray? hwId, + bool includeUnbanned) { await using var db = await GetDbImpl(); + return (await GetServerBanQueryAsync(db, address, userId, hwId, includeUnbanned)).ToList(); + } + + private async Task> GetServerBanQueryAsync( + DbGuardImpl db, + IPAddress? address, + NetUserId? userId, + ImmutableArray? hwId, + bool includeUnbanned) + { var exempt = await GetBanExemptionCore(db, userId); var newPlayer = !await db.SqliteDbContext.Player.AnyAsync(p => p.UserId == userId); @@ -111,10 +113,18 @@ namespace Content.Server.Database // So just pull down the whole list into memory. var queryBans = await GetAllBans(db.SqliteDbContext, includeUnbanned, exempt); + var playerInfo = new BanMatcher.PlayerInfo + { + Address = address, + UserId = userId, + ExemptFlags = exempt ?? default, + HWId = hwId, + IsNewPlayer = newPlayer, + }; + return queryBans - .Where(b => BanMatches(b, address, userId, hwId, exempt, newPlayer)) .Select(ConvertBan) - .ToList()!; + .Where(b => BanMatcher.BanMatches(b!, playerInfo))!; } private static async Task> GetAllBans( @@ -141,31 +151,6 @@ namespace Content.Server.Database return await query.ToListAsync(); } - private static bool BanMatches(ServerBan ban, - IPAddress? address, - NetUserId? userId, - ImmutableArray? hwId, - ServerBanExemptFlags? exemptFlags, - bool newPlayer) - { - if (!exemptFlags.GetValueOrDefault(ServerBanExemptFlags.None).HasFlag(ServerBanExemptFlags.IP) - && address != null - && ban.Address is not null - && address.IsInSubnet(ban.Address.ToTuple().Value) - && (!ban.ExemptFlags.HasFlag(ServerBanExemptFlags.BlacklistedRange) || - newPlayer)) - { - return true; - } - - if (userId is { } id && ban.PlayerUserId == id.UserId) - { - return true; - } - - return hwId is { Length: > 0 } hwIdVar && hwIdVar.AsSpan().SequenceEqual(ban.HWId); - } - public override async Task AddServerBanAsync(ServerBanDef serverBan) { await using var db = await GetDbImpl(); @@ -181,7 +166,8 @@ namespace Content.Server.Database ExpirationTime = serverBan.ExpirationTime?.UtcDateTime, RoundId = serverBan.RoundId, PlaytimeAtNote = serverBan.PlaytimeAtNote, - PlayerUserId = serverBan.UserId?.UserId + PlayerUserId = serverBan.UserId?.UserId, + ExemptFlags = serverBan.ExemptFlags }); await db.SqliteDbContext.SaveChangesAsync(); @@ -364,6 +350,7 @@ namespace Content.Server.Database } #endregion + [return: NotNullIfNotNull(nameof(ban))] private static ServerBanDef? ConvertBan(ServerBan? ban) { if (ban == null) diff --git a/Resources/Locale/en-US/info/ban.ftl b/Resources/Locale/en-US/info/ban.ftl index 463dd1f566..cc3a34c28b 100644 --- a/Resources/Locale/en-US/info/ban.ftl +++ b/Resources/Locale/en-US/info/ban.ftl @@ -82,3 +82,6 @@ ban-panel-erase = Erase chat messages and player from round server-ban-string = {$admin} created a {$severity} severity server ban that expires {$expires} for [{$name}, {$ip}, {$hwid}], with reason: {$reason} server-ban-string-no-pii = {$admin} created a {$severity} severity server ban that expires {$expires} for {$name} with reason: {$reason} server-ban-string-never = never + +# Kick on ban +ban-kick-reason = You have been banned