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
+}