Merge pull request #238 from Moonlight-Panel/AddDdosProtection

Added new ddos protection
This commit is contained in:
Marcel Baumgartner 2023-07-24 00:33:22 +02:00 committed by GitHub
commit 6d0456a008
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1575 additions and 138 deletions

View file

@ -276,6 +276,10 @@ public class ConfigV1
[Blur]
public string Token { get; set; } = Guid.NewGuid().ToString();
[JsonProperty("BlockIpDuration")]
[Description("The duration in minutes a ip will be blocked by the anti ddos system")]
public int BlockIpDuration { get; set; } = 15;
[JsonProperty("ReCaptcha")] public ReCaptchaData ReCaptcha { get; set; } = new();
}

View file

@ -42,6 +42,8 @@ public class DataContext : DbContext
public DbSet<IpBan> IpBans { get; set; }
public DbSet<PermissionGroup> PermissionGroups { get; set; }
public DbSet<SecurityLog> SecurityLogs { get; set; }
public DbSet<BlocklistIp> BlocklistIps { get; set; }
public DbSet<WhitelistIp> WhitelistIps { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{

View file

@ -0,0 +1,10 @@
namespace Moonlight.App.Database.Entities;
public class BlocklistIp
{
public int Id { get; set; }
public string Ip { get; set; } = "";
public DateTime ExpiresAt { get; set; }
public long Packets { get; set; }
public DateTime CreatedAt { get; set; }
}

View file

@ -0,0 +1,7 @@
namespace Moonlight.App.Database.Entities;
public class WhitelistIp
{
public int Id { get; set; }
public string Ip { get; set; } = "";
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Moonlight.App.Database.Migrations
{
/// <inheritdoc />
public partial class AddIpRules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BlocklistIps",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Ip = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Packets = table.Column<long>(type: "bigint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BlocklistIps", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "WhitelistIps",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Ip = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_WhitelistIps", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BlocklistIps");
migrationBuilder.DropTable(
name: "WhitelistIps");
}
}
}

View file

@ -19,6 +19,30 @@ namespace Moonlight.App.Database.Migrations
.HasAnnotation("ProductVersion", "7.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("Moonlight.App.Database.Entities.BlocklistIp", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.Property<string>("Ip")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Packets")
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("BlocklistIps");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.CloudPanel", b =>
{
b.Property<int>("Id")
@ -842,6 +866,21 @@ namespace Moonlight.App.Database.Migrations
b.ToTable("WebSpaces");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.WhitelistIp", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<string>("Ip")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.ToTable("WhitelistIps");
});
modelBuilder.Entity("Moonlight.App.Database.Entities.DdosAttack", b =>
{
b.HasOne("Moonlight.App.Database.Entities.Node", "Node")

View file

@ -3,6 +3,7 @@ using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Http.Requests.Daemon;
using Moonlight.App.Repositories;
using Moonlight.App.Services.Background;
namespace Moonlight.App.Http.Controllers.Api.Remote;
@ -10,19 +11,17 @@ namespace Moonlight.App.Http.Controllers.Api.Remote;
[Route("api/remote/ddos")]
public class DdosController : Controller
{
private readonly NodeRepository NodeRepository;
private readonly EventSystem Event;
private readonly DdosAttackRepository DdosAttackRepository;
private readonly Repository<Node> NodeRepository;
private readonly DdosProtectionService DdosProtectionService;
public DdosController(NodeRepository nodeRepository, EventSystem eventSystem, DdosAttackRepository ddosAttackRepository)
public DdosController(Repository<Node> nodeRepository, DdosProtectionService ddosProtectionService)
{
NodeRepository = nodeRepository;
Event = eventSystem;
DdosAttackRepository = ddosAttackRepository;
DdosProtectionService = ddosProtectionService;
}
[HttpPost("update")]
public async Task<ActionResult> Update([FromBody] DdosStatus ddosStatus)
[HttpPost("start")]
public async Task<ActionResult> Start([FromBody] DdosStart ddosStart)
{
var tokenData = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
var id = tokenData.Split(".")[0];
@ -35,18 +34,26 @@ public class DdosController : Controller
if (token != node.Token)
return Unauthorized();
await DdosProtectionService.ProcessDdosSignal(ddosStart.Ip, ddosStart.Packets);
return Ok();
}
var ddosAttack = new DdosAttack()
{
Ongoing = ddosStatus.Ongoing,
Data = ddosStatus.Data,
Ip = ddosStatus.Ip,
Node = node
};
[HttpPost("stop")]
public async Task<ActionResult> Stop([FromBody] DdosStop ddosStop)
{
var tokenData = Request.Headers.Authorization.ToString().Replace("Bearer ", "");
var id = tokenData.Split(".")[0];
var token = tokenData.Split(".")[1];
ddosAttack = DdosAttackRepository.Add(ddosAttack);
var node = NodeRepository.Get().FirstOrDefault(x => x.TokenId == id);
await Event.Emit("node.ddos", ddosAttack);
if (node == null)
return NotFound();
if (token != node.Token)
return Unauthorized();
return Ok();
}

View file

@ -0,0 +1,7 @@
namespace Moonlight.App.Http.Requests.Daemon;
public class DdosStart
{
public string Ip { get; set; } = "";
public long Packets { get; set; }
}

View file

@ -1,8 +0,0 @@
namespace Moonlight.App.Http.Requests.Daemon;
public class DdosStatus
{
public bool Ongoing { get; set; }
public long Data { get; set; }
public string Ip { get; set; } = "";
}

View file

@ -0,0 +1,7 @@
namespace Moonlight.App.Http.Requests.Daemon;
public class DdosStop
{
public string Ip { get; set; } = "";
public long Traffic { get; set; }
}

View file

@ -51,13 +51,6 @@ public static class Permissions
Description = "Create a new shared domain in the admin area"
};
public static Permission AdminNodeDdos = new()
{
Index = 8,
Name = "Admin Node DDoS",
Description = "Manage DDoS protection for nodes in the admin area"
};
public static Permission AdminNodeEdit = new()
{
Index = 9,
@ -407,6 +400,13 @@ public static class Permissions
Name = "Admin security logs",
Description = "View the security logs"
};
public static Permission AdminSecurityDdos = new()
{
Index = 59,
Name = "Admin security ddos",
Description = "Manage the integrated ddos protection"
};
public static Permission? FromString(string name)
{

View file

@ -0,0 +1,129 @@
using Moonlight.App.ApiClients.Daemon;
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Helpers;
using Moonlight.App.Repositories;
namespace Moonlight.App.Services.Background;
public class DdosProtectionService
{
private readonly IServiceScopeFactory ServiceScopeFactory;
public DdosProtectionService(IServiceScopeFactory serviceScopeFactory)
{
ServiceScopeFactory = serviceScopeFactory;
Task.Run(UnBlocker);
}
private async Task UnBlocker()
{
var periodicTimer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (true)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistIpRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var ips = blocklistIpRepo
.Get()
.ToArray();
foreach (var ip in ips)
{
if (DateTime.UtcNow > ip.ExpiresAt)
{
blocklistIpRepo.Delete(ip);
}
}
var newCount = blocklistIpRepo
.Get()
.Count();
if (newCount != ips.Length)
{
await RebuildNodeFirewalls();
}
await periodicTimer.WaitForNextTickAsync();
}
}
public async Task RebuildNodeFirewalls()
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistIpRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var nodeRepo = scope.ServiceProvider.GetRequiredService<Repository<Node>>();
var nodeService = scope.ServiceProvider.GetRequiredService<NodeService>();
var ips = blocklistIpRepo
.Get()
.Select(x => x.Ip)
.ToArray();
foreach (var node in nodeRepo.Get().ToArray())
{
try
{
await nodeService.RebuildFirewall(node, ips);
}
catch (Exception e)
{
Logger.Warn($"Error rebuilding firewall on node {node.Name}");
Logger.Warn(e);
}
}
}
public async Task ProcessDdosSignal(string ip, long packets)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var whitelistRepo = scope.ServiceProvider.GetRequiredService<Repository<WhitelistIp>>();
var whitelistIps = whitelistRepo.Get().ToArray();
if(whitelistIps.Any(x => x.Ip == ip))
return;
var blocklistIps = blocklistRepo.Get().ToArray();
if(blocklistIps.Any(x => x.Ip == ip))
return;
await BlocklistIp(ip, packets);
}
public async Task BlocklistIp(string ip, long packets)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var configService = scope.ServiceProvider.GetRequiredService<ConfigService>();
var eventSystem = scope.ServiceProvider.GetRequiredService<EventSystem>();
var blocklistIp = blocklistRepo.Add(new()
{
Ip = ip,
Packets = packets,
ExpiresAt = DateTime.UtcNow.AddMinutes(configService.Get().Moonlight.Security.BlockIpDuration),
CreatedAt = DateTime.UtcNow
});
await RebuildNodeFirewalls();
await eventSystem.Emit("ddos.add", blocklistIp);
}
public async Task UnBlocklistIp(string ip)
{
using var scope = ServiceScopeFactory.CreateScope();
var blocklistRepo = scope.ServiceProvider.GetRequiredService<Repository<BlocklistIp>>();
var blocklist = blocklistRepo.Get().First(x => x.Ip == ip);
blocklistRepo.Delete(blocklist);
await RebuildNodeFirewalls();
}
}

View file

@ -36,6 +36,7 @@ public class DiscordNotificationService
Event.On<User>("supportChat.close", this, OnSupportChatClose);
Event.On<User>("user.rating", this, OnUserRated);
Event.On<User>("billing.completed", this, OnBillingCompleted);
Event.On<BlocklistIp>("ddos.add", this, OnIpBlockListed);
}
else
{
@ -43,6 +44,18 @@ public class DiscordNotificationService
}
}
private async Task OnIpBlockListed(BlocklistIp blocklistIp)
{
await SendNotification("", builder =>
{
builder.Color = Color.Red;
builder.Title = "New ddos attack detected";
builder.AddField("IP", blocklistIp.Ip);
builder.AddField("Packets", blocklistIp.Packets);
});
}
private async Task OnBillingCompleted(User user)
{
await SendNotification("", builder =>

View file

@ -50,6 +50,11 @@ public class NodeService
return await DaemonApiHelper.Get<DockerMetrics>(node, "metrics/docker");
}
public async Task RebuildFirewall(Node node, string[] ips)
{
await DaemonApiHelper.Post(node, "firewall/rebuild", ips);
}
public async Task Mount(Node node, string server, string serverPath, string path)
{
await DaemonApiHelper.Post(node, "mount", new Mount()

View file

@ -244,7 +244,7 @@ namespace Moonlight
builder.Services.AddSingleton<MalwareScanService>();
builder.Services.AddSingleton<TelemetryService>();
builder.Services.AddSingleton<TempMailService>();
builder.Services.AddSingleton<DdosProtectionService>();
builder.Services.AddSingleton(pluginService);
// Other
@ -296,7 +296,7 @@ namespace Moonlight
_ = app.Services.GetRequiredService<MalwareScanService>();
_ = app.Services.GetRequiredService<TelemetryService>();
_ = app.Services.GetRequiredService<TempMailService>();
_ = app.Services.GetRequiredService<DdosProtectionService>();
_ = app.Services.GetRequiredService<MoonlightService>();
// Discord bot service

View file

@ -26,6 +26,11 @@
<TL>Logs</TL>
</a>
</li>
<li class="nav-item mt-2">
<a class="nav-link text-active-primary ms-0 me-10 py-5 @(Index == 5 ? "active" : "")" href="/admin/security/ddos">
<TL>Ddos protection</TL>
</a>
</li>
</ul>
</div>
</div>

View file

@ -1,104 +0,0 @@
@page "/admin/nodes/ddos"
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Repositories
@using BlazorTable
@using Microsoft.EntityFrameworkCore
@using Moonlight.App.Database.Entities
@using Moonlight.App.Events
@using Moonlight.App.Helpers
@using Moonlight.App.Services
@implements IDisposable
@inject DdosAttackRepository DdosAttackRepository
@inject SmartTranslateService SmartTranslateService
@inject EventSystem Event
@attribute [PermissionRequired(nameof(Permissions.AdminNodeDdos))]
<AdminNodesNavigation Index="1"/>
<LazyLoader @ref="LazyLoader" Load="Load">
<div class="card">
<div class="card-body pt-0">
<div class="table-responsive">
<Table TableItem="DdosAttack" Items="DdosAttacks" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true"/>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Status"))" Field="@(x => x.Ongoing)" Sortable="true" Filterable="true">
<Template>
@if (context.Ongoing)
{
<TL>DDos attack started</TL>
}
else
{
<TL>DDos attack stopped</TL>
}
</Template>
</Column>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Node"))" Field="@(x => x.Node)" Sortable="false" Filterable="false">
<Template>
<a href="/admin/nodes/view/@(context.Id)">
@(context.Node.Name)
</a>
</Template>
</Column>
<Column TableItem="DdosAttack" Title="Ip" Field="@(x => x.Ip)" Sortable="true" Filterable="true"/>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Status"))" Field="@(x => x.Ongoing)" Sortable="true" Filterable="true">
<Template>
@if (context.Ongoing)
{
@(context.Data)
<TL> packets</TL>
}
else
{
@(context.Data)
<span> MB</span>
}
</Template>
</Column>
<Column TableItem="DdosAttack" Title="@(SmartTranslateService.Translate("Date"))" Field="@(x => x.Ongoing)" Sortable="true" Filterable="true">
<Template>
@(Formatter.FormatDate(context.CreatedAt))
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</div>
</div>
</LazyLoader>
@code
{
private DdosAttack[] DdosAttacks;
private LazyLoader LazyLoader;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await Event.On<DdosAttack>("node.ddos", this, async attack => await LazyLoader.Reload());
}
}
private Task Load(LazyLoader arg)
{
DdosAttacks = DdosAttackRepository
.Get()
.Include(x => x.Node)
.OrderByDescending(x => x.CreatedAt)
.ToArray();
return Task.CompletedTask;
}
public async void Dispose()
{
await Event.Off("node.ddos", this);
}
//TODO: Move to security
}

View file

@ -0,0 +1,148 @@
@page "/admin/security/ddos"
@using Moonlight.Shared.Components.Navigations
@using Moonlight.App.Repositories
@using Moonlight.App.Database.Entities
@using Moonlight.App.Services
@using BlazorTable
@using Moonlight.App.Helpers
@using Moonlight.App.Services.Background
@inject Repository<BlocklistIp> BlocklistIpRepository
@inject Repository<WhitelistIp> WhitelistIpRepository
@inject SmartTranslateService SmartTranslateService
@inject DdosProtectionService DdosProtectionService
@attribute [PermissionRequired(nameof(Permissions.AdminSecurityDdos))]
<AdminSecurityNavigation Index="5"/>
<div class="card card-body mb-5">
<div class="d-flex justify-content-center fs-4">
<span class="me-3">
@(BlocklistIps.Length) <TL>blocked IPs</TL>
</span>
<span>
@(WhitelistIps.Length) <TL>whitelisted IPs</TL>
</span>
</div>
</div>
<div class="card card-body mb-5">
<div class="input-group input-group-lg">
<input @bind="Ip" type="text" class="form-control">
<WButton CssClasses="btn-secondary" OnClick="BlockIp" Text="@(SmartTranslateService.Translate("Block"))"/>
<WButton CssClasses="btn-secondary" OnClick="WhitelistIp" Text="@(SmartTranslateService.Translate("Whitelist"))"/>
</div>
</div>
<div class="card card-body mb-5">
<LazyLoader @ref="BlocklistLazyLoader" Load="LoadBlocklist">
<div class="table-responsive">
<Table TableItem="BlocklistIp" Items="BlocklistIps" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="BlocklistIp" Width="30%" Title="@(SmartTranslateService.Translate("Ip"))" Field="@(x => x.Ip)" Filterable="true" Sortable="false"/>
<Column TableItem="BlocklistIp" Width="30%" Title="@(SmartTranslateService.Translate("Packets"))" Field="@(x => x.Packets)" Filterable="true" Sortable="true">
<Template>
@(context.Packets) <TL>packets</TL>
</Template>
</Column>
<Column TableItem="BlocklistIp" Width="30%" Title="" Field="@(x => x.ExpiresAt)" Filterable="true" Sortable="true">
<Template>
@Formatter.FormatUptime(context.ExpiresAt - DateTime.UtcNow) <TL>remaining</TL>
</Template>
</Column>
<Column TableItem="BlocklistIp" Width="15%" Title="" Field="@(x => x.Id)" Filterable="false" Sortable="false">
<Template>
<div class="text-end">
<WButton Text="@(SmartTranslateService.Translate("Details"))" />
<DeleteButton Confirm="true" OnClick="() => RevokeBlocklistIp(context)" />
</div>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</LazyLoader>
</div>
<div class="card card-body">
<LazyLoader @ref="WhitelistLazyLoader" Load="LoadWhitelist">
<div class="table-responsive">
<Table TableItem="WhitelistIp" Items="WhitelistIps" PageSize="25" TableClass="table table-row-bordered table-row-gray-100 align-middle gs-0 gy-3" TableHeadClass="fw-bold text-muted">
<Column TableItem="WhitelistIp" Width="85%" Title="@(SmartTranslateService.Translate("Ip"))" Field="@(x => x.Ip)" Filterable="true" Sortable="false"/>
<Column TableItem="WhitelistIp" Width="15%" Title="" Field="@(x => x.Id)" Filterable="false" Sortable="false">
<Template>
<div class="text-end">
<DeleteButton Confirm="true" OnClick="() => RevokeWhitelistIp(context)" />
</div>
</Template>
</Column>
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
</Table>
</div>
</LazyLoader>
</div>
@code
{
private BlocklistIp[] BlocklistIps = Array.Empty<BlocklistIp>();
private WhitelistIp[] WhitelistIps = Array.Empty<WhitelistIp>();
private LazyLoader BlocklistLazyLoader;
private LazyLoader WhitelistLazyLoader;
private string Ip = "";
private async Task LoadBlocklist(LazyLoader _)
{
BlocklistIps = BlocklistIpRepository
.Get()
.ToArray();
await InvokeAsync(StateHasChanged);
}
private async Task LoadWhitelist(LazyLoader _)
{
WhitelistIps = WhitelistIpRepository
.Get()
.ToArray();
await InvokeAsync(StateHasChanged);
}
private async Task BlockIp()
{
await DdosProtectionService.BlocklistIp(Ip, -1);
Ip = "";
await BlocklistLazyLoader.Reload();
}
private async Task RevokeBlocklistIp(BlocklistIp blocklistIp)
{
await DdosProtectionService.UnBlocklistIp(blocklistIp.Ip);
await BlocklistLazyLoader.Reload();
}
private async Task WhitelistIp()
{
WhitelistIpRepository.Add(new()
{
Ip = Ip
});
Ip = "";
await WhitelistLazyLoader.Reload();
}
private async Task RevokeWhitelistIp(WhitelistIp whitelistIp)
{
WhitelistIpRepository.Delete(whitelistIp);
await WhitelistLazyLoader.Reload();
}
}