diff --git a/SS14.ServerHub.Shared/Data/TrackedCommunity.cs b/SS14.ServerHub.Shared/Data/TrackedCommunity.cs index 369ad43..e098f4f 100644 --- a/SS14.ServerHub.Shared/Data/TrackedCommunity.cs +++ b/SS14.ServerHub.Shared/Data/TrackedCommunity.cs @@ -9,34 +9,39 @@ public sealed class TrackedCommunity /// ID of this entity in the database. /// public int Id { get; set; } - + /// /// The name of this community, as displayed throughout the UI to admins. /// public string Name { get; set; } = default!; - + /// /// Any useful notes admins may want to store about this community. /// public string Notes { get; set; } = default!; - + /// /// The time this community was created by an admin. /// public DateTime Created { get; set; } - + /// /// The last time any information for this community was updated by an admin. /// public DateTime LastUpdated { get; set; } - + /// /// This community is banned, and server advertisements should be disallowed. /// public bool IsBanned { get; set; } + /// + /// This community is except from only advertising a limited amount of servers from one IP address + /// + public bool IsExemptFromMaxAdvertisements { get; set; } + // Navigation properties public List Addresses { get; set; } = default!; public List Domains { get; set; } = default!; public List InfoMatches { get; set; } = default!; -} \ No newline at end of file +} diff --git a/SS14.ServerHub.Shared/Migrations/20250929215222_MaxAdvertIP.Designer.cs b/SS14.ServerHub.Shared/Migrations/20250929215222_MaxAdvertIP.Designer.cs new file mode 100644 index 0000000..13be317 --- /dev/null +++ b/SS14.ServerHub.Shared/Migrations/20250929215222_MaxAdvertIP.Designer.cs @@ -0,0 +1,311 @@ +// +using System; +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SS14.ServerHub.Shared.Data; + +#nullable disable + +namespace SS14.ServerHub.Shared.Migrations +{ + [DbContext(typeof(HubDbContext))] + [Migration("20250929215222_MaxAdvertIP")] + partial class MaxAdvertIP + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.AdvertisedServer", b => + { + b.Property("AdvertisedServerId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AdvertisedServerId")); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("AdvertiserAddress") + .HasColumnType("inet"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("InferredTags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("InfoData") + .HasColumnType("jsonb"); + + b.Property("StatusData") + .HasColumnType("jsonb"); + + b.HasKey("AdvertisedServerId"); + + b.HasIndex("Address") + .IsUnique(); + + b.ToTable("AdvertisedServer"); + + b.HasCheckConstraint("AddressSs14Uri", "\"Address\" LIKE 'ss14://%' OR \"Address\" LIKE 'ss14s://%'"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.HubAudit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .HasColumnType("uuid"); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Time"); + + b.ToTable("HubAudit"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.ServerStatusArchive", b => + { + b.Property("AdvertisedServerId") + .HasColumnType("integer"); + + b.Property("ServerStatusArchiveId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ServerStatusArchiveId")); + + b.Property("AdvertiserAddress") + .HasColumnType("inet"); + + b.Property("InferredTags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("StatusData") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AdvertisedServerId", "ServerStatusArchiveId"); + + b.ToTable("ServerStatusArchive"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBanned") + .HasColumnType("boolean"); + + b.Property("IsExemptFromMaxAdvertisements") + .HasColumnType("boolean"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityAddress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property>("Address") + .HasColumnType("inet"); + + b.Property("TrackedCommunityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackedCommunityId"); + + b.ToTable("TrackedCommunityAddress"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DomainName") + .IsRequired() + .HasColumnType("text"); + + b.Property("TrackedCommunityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackedCommunityId"); + + b.ToTable("TrackedCommunityDomain"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Field") + .HasColumnType("integer"); + + b.Property("Path") + .IsRequired() + .HasColumnType("jsonpath"); + + b.Property("TrackedCommunityId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackedCommunityId"); + + b.ToTable("TrackedCommunityInfoMatch"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.UniqueServerName", b => + { + b.Property("AdvertisedServerId") + .HasColumnType("integer"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("FirstSeen") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSeen") + .HasColumnType("timestamp with time zone"); + + b.HasKey("AdvertisedServerId", "Name"); + + b.ToTable("UniqueServerName"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.ServerStatusArchive", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.AdvertisedServer", "AdvertisedServer") + .WithMany() + .HasForeignKey("AdvertisedServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdvertisedServer"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityAddress", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity") + .WithMany("Addresses") + .HasForeignKey("TrackedCommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityDomain", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity") + .WithMany("Domains") + .HasForeignKey("TrackedCommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunityInfoMatch", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.TrackedCommunity", "TrackedCommunity") + .WithMany("InfoMatches") + .HasForeignKey("TrackedCommunityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TrackedCommunity"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.UniqueServerName", b => + { + b.HasOne("SS14.ServerHub.Shared.Data.AdvertisedServer", "AdvertisedServer") + .WithMany() + .HasForeignKey("AdvertisedServerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdvertisedServer"); + }); + + modelBuilder.Entity("SS14.ServerHub.Shared.Data.TrackedCommunity", b => + { + b.Navigation("Addresses"); + + b.Navigation("Domains"); + + b.Navigation("InfoMatches"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SS14.ServerHub.Shared/Migrations/20250929215222_MaxAdvertIP.cs b/SS14.ServerHub.Shared/Migrations/20250929215222_MaxAdvertIP.cs new file mode 100644 index 0000000..f0f80ac --- /dev/null +++ b/SS14.ServerHub.Shared/Migrations/20250929215222_MaxAdvertIP.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SS14.ServerHub.Shared.Migrations +{ + public partial class MaxAdvertIP : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsExemptFromMaxAdvertisements", + table: "TrackedCommunity", + type: "boolean", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsExemptFromMaxAdvertisements", + table: "TrackedCommunity"); + } + } +} diff --git a/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs b/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs index 0afba49..7239126 100644 --- a/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs +++ b/SS14.ServerHub.Shared/Migrations/HubDbContextModelSnapshot.cs @@ -134,6 +134,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("IsBanned") .HasColumnType("boolean"); + b.Property("IsExemptFromMaxAdvertisements") + .HasColumnType("boolean"); + b.Property("LastUpdated") .HasColumnType("timestamp with time zone"); diff --git a/SS14.ServerHub/Controllers/ServerListController.cs b/SS14.ServerHub/Controllers/ServerListController.cs index a2cf1c4..f80481b 100644 --- a/SS14.ServerHub/Controllers/ServerListController.cs +++ b/SS14.ServerHub/Controllers/ServerListController.cs @@ -53,7 +53,7 @@ public async Task> Get() return dbInfos; } - + [EnableCors(CorsPolicies.PolicyHubPublic)] [HttpGet("info")] public async Task GetServerInfo(string url) @@ -65,7 +65,7 @@ public async Task GetServerInfo(string url) if (dbInfo == null) return NotFound(); - + return Ok((RawJson?) dbInfo.InfoData); } @@ -124,6 +124,24 @@ parsedAddress.Scheme is not (Ss14UriHelper.SchemeSs14 or Ss14UriHelper.SchemeSs1 var addressEntity = await _dbContext.AdvertisedServer.SingleOrDefaultAsync(a => a.Address == advertise.Address); + if (senderIp != null) + { + // Check the current number of advertised servers from this IP. + var count = await _dbContext.AdvertisedServer + .Where(s => s.AdvertiserAddress == senderIp) + .Where(s => s.Expires > DateTime.UtcNow) + .CountAsync(); + + var isServerRenewingAdvertisement = addressEntity != null && addressEntity.Expires > DateTime.UtcNow; + + if (!isServerRenewingAdvertisement + && count >= HubOptions.MaxServersPerIp + && !await CheckExemptFromMaxAdvertisements(parsedAddress)) + { + return Unauthorized($"You cannot advertise more than {HubOptions.MaxServersPerIp} servers from one IP address, please contact us if you require an increase."); + } + } + var timeNow = DateTime.UtcNow; var newExpireTime = timeNow + TimeSpan.FromMinutes(options.AdvertisementExpireMinutes); if (addressEntity == null) @@ -149,7 +167,7 @@ parsedAddress.Scheme is not (Ss14UriHelper.SchemeSs14 or Ss14UriHelper.SchemeSs1 StatusData = statusJson, InferredTags = inferredTags }); - + await _dbContext.SaveChangesAsync(); return NoContent(); } @@ -176,11 +194,11 @@ parsedAddress.Scheme is not (Ss14UriHelper.SchemeSs14 or Ss14UriHelper.SchemeSs1 { return (UnprocessableEntity($"/status response data was too large (max: {maxStatusSize} KiB)"), null, null); } - + var statusData = JsonSerializer.Deserialize(statusResponse); if (statusData == null) throw new InvalidDataException("Status cannot be null"); - + if (string.IsNullOrWhiteSpace(statusData.Name)) return (UnprocessableEntity("Server name cannot be empty"), null, null); @@ -270,6 +288,15 @@ private BanCheckResult CheckMatchedCommunitiesForBan(Uri address, List b.TrackedCommunity.IsBanned); } + private async Task CheckExemptFromMaxAdvertisements(Uri advertisementUri) + { + var matched = new List(); + + await CommunityMatcher.MatchCommunities(_dbContext, advertisementUri, matched, CancellationToken.None); + + return matched.FirstOrDefault(x => x.IsExemptFromMaxAdvertisements) != null; + } + private static string[] InferTags(byte[] statusDataJson) { var statusData = JsonSerializer.Deserialize(statusDataJson)!; diff --git a/SS14.ServerHub/HubOptions.cs b/SS14.ServerHub/HubOptions.cs index 6cbf09d..15bfd62 100644 --- a/SS14.ServerHub/HubOptions.cs +++ b/SS14.ServerHub/HubOptions.cs @@ -3,7 +3,7 @@ public sealed class HubOptions { public const string Position = "Hub"; - + public float AdvertisementExpireMinutes { get; set; } = 3; /// @@ -22,9 +22,14 @@ public sealed class HubOptions /// When fetching /status from advertised servers, maximum size of response bodies in kilobytes. /// public int MaxStatusResponseSize = 2; - + /// /// When fetching /info from advertised servers, maximum size of response bodies in kilobytes. /// public int MaxInfoResponseSize = 10; -} \ No newline at end of file + + /// + /// What is the maximum number of servers one ip address can advertise? + /// + public const int MaxServersPerIp = 2; +} diff --git a/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml b/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml index acb0951..fac9b1e 100644 --- a/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml +++ b/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml @@ -38,8 +38,17 @@ +
+
+
+ + +
+
+
+ - +
Addresses
@@ -59,11 +68,11 @@
- + } @@ -92,11 +101,11 @@
- + } diff --git a/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml.cs b/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml.cs index 4e66855..8a9ee76 100644 --- a/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml.cs +++ b/SS14.Web/Areas/Admin/Pages/Servers/Communities/View.cshtml.cs @@ -29,7 +29,7 @@ public sealed class AddAddressModel { public string Address { get; set; } } - + public sealed class AddDomainModel { public string Domain { get; set; } @@ -46,14 +46,15 @@ public sealed class InputModel public string Name { get; set; } public string Notes { get; set; } public bool IsBanned { get; set; } + public bool IsExemptFromMaxAdvertisements { get; set; } } - + public View(HubDbContext dbContext, HubAuditLogManager hubAuditLog) { _dbContext = dbContext; _hubAuditLog = hubAuditLog; } - + public async Task OnGetAsync(int id) { Community = await _dbContext.TrackedCommunity @@ -62,7 +63,7 @@ public async Task OnGetAsync(int id) .Include(c => c.InfoMatches) .AsSplitQuery() .SingleOrDefaultAsync(u => u.Id == id); - + if (Community == null) return NotFound("Community not found"); @@ -70,9 +71,10 @@ public async Task OnGetAsync(int id) { Name = Community.Name, IsBanned = Community.IsBanned, + IsExemptFromMaxAdvertisements = Community.IsExemptFromMaxAdvertisements, Notes = Community.Notes }; - + return Page(); } @@ -86,7 +88,7 @@ public async Task OnPostSaveAsync(int id) var inputNotes = Input.Notes ?? ""; var anyChange = false; - + if (Community.Name != inputName) { _hubAuditLog.Log(User, new HubAuditCommunityChangedName(Community, Community.Name, inputName)); @@ -108,6 +110,13 @@ public async Task OnPostSaveAsync(int id) anyChange = true; } + if (Community.IsExemptFromMaxAdvertisements != Input.IsExemptFromMaxAdvertisements) + { + _hubAuditLog.Log(User, new HubAuditCommunityChangedBanned(Community, Community.IsExemptFromMaxAdvertisements, Input.IsExemptFromMaxAdvertisements)); + Community.IsExemptFromMaxAdvertisements = Input.IsExemptFromMaxAdvertisements; + anyChange = true; + } + if (!anyChange) return RedirectToPage(new { id = Community.Id }); @@ -119,11 +128,11 @@ public async Task OnPostSaveAsync(int id) return RedirectToPage(new { id = Community.Id }); } - + public async Task OnPostAddAddressAsync(int id) { await using var tx = await _dbContext.Database.BeginTransactionAsync(); - + Community = await _dbContext.TrackedCommunity.SingleOrDefaultAsync(c => c.Id == id); if (Community == null) return NotFound("Community not found"); @@ -138,29 +147,29 @@ public async Task OnPostAddAddressAsync(int id) { Address = cidr, TrackedCommunityId = id }; - + _dbContext.TrackedCommunityAddress.Add(address); Community.LastUpdated = DateTime.UtcNow; await _dbContext.SaveChangesAsync(); _hubAuditLog.Log(User, new HubAuditCommunityAddressAdd(Community, address)); - + await _dbContext.SaveChangesAsync(); await tx.CommitAsync(); - + StatusMessage = "Address added"; return RedirectToPage(new { id = Community.Id }); } - + public async Task OnPostDeleteAddressAsync(int address) { var addressEnt = await _dbContext.TrackedCommunityAddress .Include(c => c.TrackedCommunity) .SingleOrDefaultAsync(c => c.Id == address); - + if (addressEnt == null) return NotFound("Address not found"); @@ -174,7 +183,7 @@ public async Task OnPostDeleteAddressAsync(int address) return RedirectToPage(new { id = addressEnt.TrackedCommunityId }); } - + public async Task OnPostAddDomainAsync(int id) { await using var tx = await _dbContext.Database.BeginTransactionAsync(); @@ -200,22 +209,22 @@ public async Task OnPostAddDomainAsync(int id) await _dbContext.SaveChangesAsync(); _hubAuditLog.Log(User, new HubAuditCommunityDomainAdd(Community, domainEnt)); - + await _dbContext.SaveChangesAsync(); - + await tx.CommitAsync(); - + StatusMessage = "Domain added"; return RedirectToPage(new { id = Community.Id }); } - + public async Task OnPostDeleteDomainAsync(int domain) { var domainEnt = await _dbContext.TrackedCommunityDomain .Include(c => c.TrackedCommunity) .SingleOrDefaultAsync(c => c.Id == domain); - + if (domainEnt == null) return NotFound("Domain not found"); @@ -319,4 +328,4 @@ private async Task InsertInfoMatch( return await _dbContext.TrackedCommunityInfoMatch.SingleAsync(x => x.Id == result); } -} \ No newline at end of file +}