Add maintenance cleanup for revoked and orphaned configs
Two new maintenance actions remove VpnConfig records (with their traffic samples) from the database: all revoked configs, and configs whose inbound or node no longer exists. 3x-ui is not touched; both write an AuditLog entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1bc86dc426
commit
2c20fb0427
12 files changed
+367
-1
No files matched your search
@@ -23,6 +23,12 @@ public static class AdminMaintenanceEndpoints
|
||||
admin
|
||||
.MapDelete("/apps/disabled", DeleteDisabledApps)
|
||||
.Produces<MaintenanceCleanupResponseDto>();
|
||||
admin
|
||||
.MapDelete("/configs/revoked", DeleteRevokedConfigs)
|
||||
.Produces<MaintenanceCleanupResponseDto>();
|
||||
admin
|
||||
.MapDelete("/configs/orphaned", DeleteOrphanedConfigs)
|
||||
.Produces<MaintenanceCleanupResponseDto>();
|
||||
admin.MapDelete("/factory-reset", FactoryReset).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
return app;
|
||||
@@ -59,6 +65,24 @@ public static class AdminMaintenanceEndpoints
|
||||
return ToResponse(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteRevokedConfigs(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteRevokedConfigsCommand(), cancellationToken);
|
||||
return ToResponse(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> DeleteOrphanedConfigs(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteOrphanedConfigsCommand(), cancellationToken);
|
||||
return ToResponse(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> FactoryReset(
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
/// <summary>Общее удаление конфигов для команд обслуживания: TrafficSample ссылается на конфиг без
|
||||
/// FK-каскада, поэтому историю трафика удаляем явно вместе с конфигами.</summary>
|
||||
internal static class ConfigCleanup
|
||||
{
|
||||
public static async Task RemoveWithTrafficAsync(
|
||||
IAppDbContext dbContext,
|
||||
IReadOnlyCollection<VpnConfig> configs,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var configIds = configs.Select(c => c.Id).ToList();
|
||||
var samples = await dbContext
|
||||
.TrafficSamples.Where(s => configIds.Contains(s.ConfigId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
dbContext.TrafficSamples.RemoveRange(samples);
|
||||
dbContext.VpnConfigs.RemoveRange(configs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using LiteCqrs;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
/// <summary>Удаляет из БД конфиги, чей инбаунд или нода уже удалены (в списке — «? · ?»), в любом
|
||||
/// статусе, вместе с их историей трафика. Достучаться до 3x-ui для них некуда, поэтому панель не
|
||||
/// трогаем. Value — число удалённых конфигов.</summary>
|
||||
public sealed record DeleteOrphanedConfigsCommand : ICommand<Result<int>>;
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using LiteCqrs;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
public sealed class DeleteOrphanedConfigsCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteOrphanedConfigsCommand, Result<int>>
|
||||
{
|
||||
public async Task<Result<int>> Handle(
|
||||
DeleteOrphanedConfigsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure<int>(AuthErrors.Unauthorized);
|
||||
|
||||
// Инбаунд жив только вместе со своей нодой — «сиротой» считается и конфиг на инбаунде
|
||||
// удалённой ноды.
|
||||
var liveInboundIds = dbContext
|
||||
.Inbounds.Where(i => dbContext.Nodes.Any(n => n.Id == i.NodeId))
|
||||
.Select(i => i.Id);
|
||||
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => !liveInboundIds.Contains(c.InboundId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (configs.Count == 0)
|
||||
return Result.Success(0);
|
||||
|
||||
await ConfigCleanup.RemoveWithTrafficAsync(dbContext, configs, cancellationToken);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"OrphanedConfigsCleanedUp",
|
||||
"VpnConfig",
|
||||
"bulk",
|
||||
metadata: $"{{\"count\":{configs.Count}}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(configs.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using LiteCqrs;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
/// <summary>Удаляет из БД все конфиги в статусе Revoked вместе с их историей трафика. В 3x-ui их
|
||||
/// клиентов уже нет (отзыв удаляет клиента), поэтому панель не трогаем. Value — число удалённых
|
||||
/// конфигов.</summary>
|
||||
public sealed record DeleteRevokedConfigsCommand : ICommand<Result<int>>;
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PnvPanel.Application.Auth;
|
||||
using PnvPanel.Application.Common.Interfaces;
|
||||
using LiteCqrs;
|
||||
using PnvPanel.Application.Common.Models;
|
||||
using PnvPanel.Domain.Audit;
|
||||
using PnvPanel.Domain.Configs;
|
||||
|
||||
namespace PnvPanel.Application.Admin.Maintenance;
|
||||
|
||||
public sealed class DeleteRevokedConfigsCommandHandler(
|
||||
IAppDbContext dbContext,
|
||||
ICurrentUser currentUser
|
||||
) : ICommandHandler<DeleteRevokedConfigsCommand, Result<int>>
|
||||
{
|
||||
public async Task<Result<int>> Handle(
|
||||
DeleteRevokedConfigsCommand command,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (currentUser.UserId is not { } adminId)
|
||||
return Result.Failure<int>(AuthErrors.Unauthorized);
|
||||
|
||||
var configs = await dbContext
|
||||
.VpnConfigs.Where(c => c.Status == ConfigStatus.Revoked)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (configs.Count == 0)
|
||||
return Result.Success(0);
|
||||
|
||||
await ConfigCleanup.RemoveWithTrafficAsync(dbContext, configs, cancellationToken);
|
||||
|
||||
dbContext.AuditLogs.Add(
|
||||
AuditLog.Create(
|
||||
adminId,
|
||||
"RevokedConfigsCleanedUp",
|
||||
"VpnConfig",
|
||||
"bulk",
|
||||
metadata: $"{{\"count\":{configs.Count}}}",
|
||||
AuditSource.Web
|
||||
)
|
||||
);
|
||||
|
||||
return Result.Success(configs.Count);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
using PnvPanel.Application.Admin.Maintenance;
|
||||
using PnvPanel.Application.Tests.TestSupport;
|
||||
using PnvPanel.Domain.Configs;
|
||||
using PnvPanel.Domain.Inbounds;
|
||||
using PnvPanel.Domain.Nodes;
|
||||
using Xunit;
|
||||
|
||||
namespace PnvPanel.Application.Tests.Admin.Maintenance;
|
||||
|
||||
public class DeleteConfigsCommandHandlerTests
|
||||
{
|
||||
private static Node CreateNode() =>
|
||||
Node.Register(
|
||||
"node-1",
|
||||
new Uri("https://node-1.example.com"),
|
||||
new NodeCredentials("admin", "protected"),
|
||||
null
|
||||
);
|
||||
|
||||
private static VpnConfig CreateConfig(Guid inboundId) =>
|
||||
VpnConfig.Create(Guid.NewGuid(), inboundId, VpnProtocol.Vless, null);
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteRevoked_DeletesRevokedConfigsAndTheirTraffic_KeepsOthers()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var node = CreateNode();
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443);
|
||||
var revoked = CreateConfig(inbound.Id);
|
||||
revoked.Revoke();
|
||||
var active = CreateConfig(inbound.Id);
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.AddRange(revoked, active);
|
||||
dbContext.TrafficSamples.AddRange(
|
||||
TrafficSample.Create(revoked.Id, DateTimeOffset.UtcNow, 1, 2),
|
||||
TrafficSample.Create(active.Id, DateTimeOffset.UtcNow, 3, 4)
|
||||
);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new DeleteRevokedConfigsCommandHandler(
|
||||
dbContext,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(new DeleteRevokedConfigsCommand(), CancellationToken.None);
|
||||
// Хендлер не коммитит сам (в проде это делает UnitOfWorkBehavior после диспетчера) — коммитим явно.
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(1, result.Value);
|
||||
Assert.Equal([active.Id], dbContext.VpnConfigs.Select(c => c.Id).ToList());
|
||||
Assert.Equal([active.Id], dbContext.TrafficSamples.Select(s => s.ConfigId).ToList());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOrphaned_DeletesConfigsWithoutInboundOrNode_KeepsLiveOnes()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var node = CreateNode();
|
||||
var liveInbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "live", 443);
|
||||
// Инбаунд, чья нода удалена.
|
||||
var orphanInbound = Inbound.FromRemote(Guid.NewGuid(), "2", VpnProtocol.Vless, "orphan", 444);
|
||||
|
||||
var live = CreateConfig(liveInbound.Id);
|
||||
var onDeletedNode = CreateConfig(orphanInbound.Id);
|
||||
var onDeletedInbound = CreateConfig(Guid.NewGuid());
|
||||
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.AddRange(liveInbound, orphanInbound);
|
||||
dbContext.VpnConfigs.AddRange(live, onDeletedNode, onDeletedInbound);
|
||||
dbContext.TrafficSamples.Add(
|
||||
TrafficSample.Create(onDeletedInbound.Id, DateTimeOffset.UtcNow, 1, 2)
|
||||
);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new DeleteOrphanedConfigsCommandHandler(
|
||||
dbContext,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(new DeleteOrphanedConfigsCommand(), CancellationToken.None);
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value);
|
||||
Assert.Equal([live.Id], dbContext.VpnConfigs.Select(c => c.Id).ToList());
|
||||
Assert.Empty(dbContext.TrafficSamples);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteOrphaned_WhenNothingToDelete_ReturnsZero()
|
||||
{
|
||||
using var dbContext = InMemoryDbContextFactory.Create();
|
||||
var node = CreateNode();
|
||||
var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "live", 443);
|
||||
dbContext.Nodes.Add(node);
|
||||
dbContext.Inbounds.Add(inbound);
|
||||
dbContext.VpnConfigs.Add(CreateConfig(inbound.Id));
|
||||
await dbContext.SaveChangesAsync(CancellationToken.None);
|
||||
|
||||
var handler = new DeleteOrphanedConfigsCommandHandler(
|
||||
dbContext,
|
||||
FakeCurrentUser.Authenticated(Guid.NewGuid(), "admin")
|
||||
);
|
||||
|
||||
var result = await handler.Handle(new DeleteOrphanedConfigsCommand(), CancellationToken.None);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(0, result.Value);
|
||||
Assert.Single(dbContext.VpnConfigs);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user